From 5ca2f448d41929fb908743ac52848652ab7e5933 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 2 Jun 2026 21:51:25 +0200 Subject: [PATCH] =?UTF-8?q?fix(physics):=20R1=20membership=20=E2=80=94=20c?= =?UTF-8?q?urrent-cell-first=20hysteresis=20in=20find=5Fcell=5Flist=20pick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.Core/Physics/CellTransit.cs | 19 ++++++++ .../Physics/CellTransitFindCellSetTests.cs | 44 +++++++++++++++++++ 2 files changed, 63 insertions(+) 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 + } }