fix(physics): R1 membership — current-cell-first hysteresis in find_cell_list pick

The flap R1 exposed is a cell-membership ping-pong: the find_cell_list containing-
cell pick (CellTransit.BuildCellSetAndPickContaining) iterated an UNORDERED HashSet
and returned the first interior cell whose BSP contains the sphere center, with no
preference for the current cell. Retail CObjCell::find_cell_list adds the current
cell at index 0 (add_cell, pc:308766) and iterates current-first with interior-wins-
break (pc:308791-308819) — you STAY in your current cell until the center genuinely
leaves it. acdream's HashSet dropped that ordering; once the candidate set churns at
a boundary the enumeration can surface a neighbour before the current cell → the
ping-pong. Restore the explicit, deterministic current-cell-first test (retail's
index-0 hysteresis). + a two-direction regression guard (current cell wins the
straddle).

Diagnosed from the existing [cell-transit] walk log (no new probing): room flips are
the pick non-determinism; stairs flips additionally show the foot Z oscillating
~0.2m/tick (a separate stairs-physics residual, #98 family, to verify after this).

The 2 DoorBugTrajectoryReplay failures are PRE-EXISTING (verified: they fail without
this change too) — 2 of the handoff's '3 door-collision apparatus / A6.P5'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-02 21:51:25 +02:00
parent 58822fed96
commit 5ca2f448d4
2 changed files with 63 additions and 0 deletions

View file

@ -519,6 +519,25 @@ public static class CellTransit
PhysicsDiagnostics.LogCellSetBuild(currentCellId, worldSphereCenter, candidates);
}
// Retail CObjCell::find_cell_list checks the CURRENT cell FIRST — it adds it at index 0
// (add_cell, pseudo_c:308766) and the pick loop iterates from index 0 with interior-wins-break
// (pseudo_c:308791-308819). So if the sphere center is still inside the current cell, it wins
// and the search stops: you STAY in your current cell until the center genuinely leaves it,
// never flipping to an overlapping neighbour. acdream's unordered HashSet candidate set dropped
// that ordering — once the set churns at a boundary the enumeration can surface a neighbour
// before the current cell, producing the membership ping-pong (the R1 flap). Restore the
// explicit, deterministic current-cell-first test (the retail hysteresis):
if (currentLow >= 0x0100u)
{
var curCell = cache.GetCellStruct(currentCellId);
if (curCell?.CellBSP?.Root is not null)
{
var localCur = Vector3.Transform(worldSphereCenter, curCell.InverseWorldTransform);
if (BSPQuery.PointInsideCellBsp(curCell.CellBSP.Root, localCur))
return currentCellId; // still inside the current cell → stay (retail index-0)
}
}
// Retail CObjCell::find_cell_list containing-cell pick (pseudo_c:308788-308819):
// INTERIOR-WINS — the first EnvCell whose point_in_cell (BSP) contains the sphere center
// wins and stops the search. Only if no interior cell contains it do we fall to the

View file

@ -158,4 +158,48 @@ public class CellTransitFindCellSetTests
Assert.Contains(0xA9B40100u, cellSet);
Assert.Contains(0xA9B40001u, cellSet);
}
// ──────────────────────────────────────────────────────────────────
// Membership hysteresis — the R1-flap root cause.
// Retail CObjCell::find_cell_list adds the CURRENT cell at index 0
// (add_cell, pc:308766) and the pick loop iterates from index 0 with
// interior-wins-break (pc:308791-308819) — so when two cells' BSPs both
// contain the sphere center at a boundary, the CURRENT cell wins and the
// membership does NOT flip. acdream's unordered-HashSet pick dropped that
// ordering, producing the stair/room/doorway ping-pong (cell flips every
// tick while straddling). This guards the current-cell-first behaviour.
// ──────────────────────────────────────────────────────────────────
[Theory]
[InlineData(0xA9B40100u, 0xA9B40101u)]
[InlineData(0xA9B40101u, 0xA9B40100u)]
public void TwoOverlappingCells_CurrentCellWinsTheStraddle(uint currentCellId, uint otherCellId)
{
// Both cells use a Leaf BSP (contains any point) and are reciprocally portalled at the same
// plane (local x=2.5), so the candidate set is {both} from EITHER seed and the sphere center
// is inside BOTH cells' BSPs. The pick must return the CURRENT cell (hysteresis), not flip to
// the neighbour. Testing both seed directions guarantees a deterministic failure on the
// unordered pick (it returns the same enumeration-first cell regardless of which is current).
ushort currentLow = (ushort)(currentCellId & 0xFFFF);
ushort otherLow = (ushort)(otherCellId & 0xFFFF);
var current = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherLow, flags: 0);
var other = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, currentLow, flags: 0);
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(currentCellId, current);
cache.RegisterCellStructForTest(otherCellId, other);
// Center straddles the portal plane at local x=2.5 (both cells), inside both Leaf BSPs.
var sphereCenter = new Vector3(2.0f, 0f, 2.5f);
uint containing = CellTransit.FindCellSet(
cache, sphereCenter, sphereRadius: 0.5f,
currentCellId: currentCellId,
out var cellSet);
Assert.Contains(currentCellId, cellSet);
Assert.Contains(otherCellId, cellSet);
Assert.Equal(currentCellId, containing); // retail current-cell-first hysteresis
}
}