fix(physics): #90 — sphere-overlap cell stickiness at doorway threshold

ResolveCellId's indoor-seed fall-through was point-only: when the indoor
BSP push-back moved the foot-sphere CENTER a few cm outside the indoor
CellBSP volume, the resolver flipped CellId back to outdoor. Next tick
re-promoted via CheckBuildingTransit. The ping-pong caused most ticks
to be classified outdoor, bypassing indoor BSP wall checks entirely
and producing the user-reported "walls walk through everywhere in the
inn" symptom.

Fix: port retail's BSPTREE::sphere_intersects_cell_bsp
(acclient_2013_pseudo_c.txt:323267 → BSPNODE variant at :325546) as
BSPQuery.SphereIntersectsCellBsp(node, center, radius). Replace the
point-only check at PhysicsEngine.ResolveCellId:285 with the radius-
aware overlap test. Player stays classified indoor as long as ANY
part of the foot-sphere still overlaps the indoor cell volume; only
flips to outdoor when the sphere is FULLY outside.

Retail uses a 0.01 m epsilon on the radius (acclient :325551); ported
verbatim. 8 new unit tests cover null/leaf/inside/on-plane/straddling/
fully-outside/tangent-boundary cases plus a regression-anchor test
that proves the old PointInsideCellBsp would have returned false for
the same straddling input.

1147 + 8 baseline maintained (was 1139 + 8 before #90 fix). Closes #90.
A4 multi-cell iteration (shipped earlier today) should now actually
exercise in production since the player can stably remain in indoor
cells.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-20 20:30:36 +02:00
parent 1534990102
commit 4ca35966f8
3 changed files with 172 additions and 3 deletions

View file

@ -962,6 +962,51 @@ public static class BSPQuery
return false;
}
/// <summary>
/// Issue #90 fix (2026-05-20). Radius-aware sibling of
/// <see cref="PointInsideCellBsp"/>. Returns true if a sphere centered
/// at <paramref name="center"/> with radius <paramref name="radius"/>
/// overlaps the cell volume — i.e., the sphere is fully inside OR
/// straddles any splitting plane. Returns false only when the sphere
/// is FULLY outside (behind some splitting plane by more than its
/// radius).
///
/// <para>
/// Used by <see cref="PhysicsEngine.ResolveCellId"/> to avoid
/// ping-ponging the CellId back to outdoor on each indoor BSP
/// push-back: when the indoor BSP pushes the foot-sphere back by
/// a few cm, the sphere CENTER may briefly leave the indoor
/// CellBSP volume, but the sphere itself still OVERLAPS the cell.
/// The point-only test would flip CellId to outdoor; this overlap
/// test keeps the player classified as indoor as long as any part
/// of the sphere is still inside the cell.
/// </para>
///
/// <para>
/// Retail oracle: <c>BSPTREE::sphere_intersects_cell_bsp</c> at
/// <c>acclient_2013_pseudo_c.txt:323267</c> →
/// <c>BSPNODE::sphere_intersects_cell_bsp</c> at
/// <c>:325546</c>. Retail adds a 0.01f epsilon to the radius before
/// the threshold compare; we match that.
/// </para>
/// </summary>
public static bool SphereIntersectsCellBsp(CellBSPNode? node, Vector3 center, float radius)
{
if (node is null) return true;
if (node.Type == BSPNodeType.Leaf) return true;
float dist = Vector3.Dot(node.SplittingPlane.Normal, center) + node.SplittingPlane.D;
float rad = radius + 0.01f; // retail's epsilon at :325551
// Behind splitting plane by more than radius → sphere fully outside.
if (dist < -rad) return false;
// Otherwise (front, on-plane, or straddling within radius) → follow positive child.
return node.PosNode is not null
? SphereIntersectsCellBsp(node.PosNode, center, radius)
: true;
}
// =========================================================================
// BSP TREE-LEVEL HELPERS
//

View file

@ -281,12 +281,26 @@ public sealed class PhysicsEngine
if (indoorCell?.CellBSP?.Root is null)
return indoorResult; // Can't verify (no CellBSP); trust FindCellList.
// Issue #90 fix (2026-05-20): use SPHERE-overlap instead of POINT-in
// for the indoor verification. The previous point-only check caused
// a per-frame ping-pong at the inn doorway: indoor BSP push-back
// moved the sphere CENTER a few cm outside the indoor CellBSP
// volume → point-only check returned false → fell through to outdoor
// → next tick re-promoted to indoor → wall hit → push-back →
// outdoor → repeat. Net visual behavior: "walls walk through"
// because outdoor ticks bypass indoor BSP entirely. With sphere-
// overlap, the player stays classified indoor as long as ANY part
// of the foot sphere still overlaps the indoor cell volume.
//
// Retail oracle: CCellStruct::sphere_intersects_cell at
// acclient_2013_pseudo_c.txt:317666 →
// BSPTREE::sphere_intersects_cell_bsp at :323267.
var localCenter = Vector3.Transform(worldPos, indoorCell.InverseWorldTransform);
if (BSPQuery.PointInsideCellBsp(indoorCell.CellBSP.Root, localCenter))
if (BSPQuery.SphereIntersectsCellBsp(indoorCell.CellBSP.Root, localCenter, sphereRadius))
return indoorResult;
// Fall through to outdoor resolution: player has left the indoor
// portal-connected graph entirely.
// Fall through to outdoor resolution: player has FULLY left the
// indoor portal-connected graph (sphere no longer overlaps).
}
// Outdoor seed: use terrain grid to compute the prefixed cell id.