diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 3fcd4c0..4a6f964 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -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 diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs index 33adf01..a08eae6 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs @@ -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 + } }