diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index ae19e70..915f571 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -428,59 +428,52 @@ public static class CellTransit IReadOnlyList worldSpheres, int numSpheres, uint currentCellId, - out HashSet candidates) + out CellArray candidates) { - candidates = new HashSet(); + // Ordered, deduped candidate array — retail CELLARRAY (add_cell @701036). + // The ORDER is load-bearing: the current cell is added at index 0 and the + // pick iterates in order with interior-wins-break, so the current cell wins + // a boundary straddle and the membership does not ping-pong (the R1 flap). + candidates = new CellArray(); int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres); if (sphereCount == 0) return currentCellId; Vector3 worldSphereCenter = worldSpheres[0].Origin; float sphereRadius = worldSpheres[0].Radius; uint currentLow = currentCellId & 0xFFFFu; + uint lbPrefix = currentCellId & 0xFFFF0000u; if (currentLow >= 0x0100u) { - // Indoor seed. + // Indoor seed: the CURRENT cell is added at INDEX 0 (retail + // CObjCell::find_cell_list add_cell @ pseudo_c:308766). Index 0 is what + // makes the pick current-cell-first — the hysteresis that stops the flap. var currentCell = cache.GetCellStruct(currentCellId); if (currentCell is null) return currentCellId; - candidates.Add(currentCellId); - // BFS the portal graph (one hop per pass — usually 1-2 passes is enough). - var pending = new Queue(); - var visited = new HashSet(); - pending.Enqueue(currentCellId); - visited.Add(currentCellId); - int maxIterations = 16; // hard cap; portal graphs are small + // EXPAND — a single forward walk over the GROWING array, mirroring + // retail's `for (i=0; i 0 && maxIterations-- > 0) + for (int i = 0; i < candidates.Count; i++) { - uint cellId = pending.Dequeue(); + uint cellId = candidates.OrderedIds[i]; var cell = cache.GetCellStruct(cellId); if (cell is null) continue; - var sizeBefore = candidates.Count; FindTransitCellsSphere( cache, cell, cellId, worldSpheres, sphereCount, candidates, out bool exitOutside); - if (candidates.Count > sizeBefore) - { - foreach (var c in candidates) - { - if (visited.Add(c)) // only enqueue if NEW - pending.Enqueue(c); - } - } - - // A6.P5 (2026-05-25): any BFS-visited cell with an exit - // portal triggers the outdoor-neighbourhood add — matches - // retail's CObjCell::find_cell_list at - // acclient_2013_pseudo_c.txt:308742-308869 which expands - // portal-reachable cells unconditionally via vtable[0x80]. - // Dedupe to once per BFS — the radial pattern depends only - // on the seed cell + sphere XY, so repeated calls would - // be no-ops with extra HashSet overhead. + // A6.P5 (kept): the first exit-portal cell triggers the outdoor + // neighbourhood add once. Appended AFTER the interior cells, matching + // retail (CEnvCell::find_transit_cells calls add_all_outside_cells at + // the end, pseudo_c:310120) — so interior cells precede outdoor in the + // pick order and interior-wins is preserved. if (exitOutside && !outdoorAdded) { AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, candidates); @@ -490,22 +483,12 @@ public static class CellTransit } else { - // Outdoor seed: expand neighbour landcells AND check for building stabs - // with portals into interior EnvCells. + // Outdoor seed: expand neighbour landcells (added first), then check each + // for a building stab whose portals cross into an interior EnvCell. + // (Stage 2 will make building entry intrinsic and remove CheckBuildingTransit.) AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, candidates); - // For each landcell candidate, see if it carries a building stab; if so, - // check whether the sphere has crossed into any of the building's interior - // EnvCells via CheckBuildingTransit. - // - // NOTE: PhysicsEngine.ResolveCellId currently bypasses this entire branch - // for outdoor seeds (it uses its own _landblocks terrain grid loop). The - // outdoor→indoor production path therefore runs through ResolveCellId's - // OWN outdoor branch (see below for the call there too). This block is - // exercised by direct-FindCellList callers (tests, future re-entry from - // an indoor cell exiting through a portal that lands outside near a - // building). - var landcellSnapshot = new List(candidates); + var landcellSnapshot = new List(candidates.OrderedIds); foreach (uint landcellId in landcellSnapshot) { var building = cache.GetBuilding(landcellId); @@ -515,59 +498,49 @@ public static class CellTransit } if (PhysicsDiagnostics.ProbeCellSetEnabled) - { 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) + // THE PICK — verbatim CObjCell::find_cell_list containing-cell pick + // (pseudo_c:308788-308825): iterate the array IN ORDER from index 0; for each + // cell, point_in_cell; set the running result on ANY containing cell; + // INTERIOR-WINS-BREAK. The current cell is at index 0, so if the sphere centre + // is still inside it, it wins and the search stops — the retail hysteresis. + // (Replaces the 5ca2f44 current-first pre-check, which approximated this for + // the indoor-current case only; the ordered array now delivers it for every + // seed by construction.) + uint outdoorResult = 0u; + foreach (uint candId in candidates.OrderedIds) { - var curCell = cache.GetCellStruct(currentCellId); - if (curCell?.CellBSP?.Root is not null) + if ((candId & 0xFFFFu) >= 0x0100u) { - 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) + // Interior candidate — point_in_cell via the cell BSP (vtable[0x84]). + var cand = cache.GetCellStruct(candId); + if (cand?.CellBSP?.Root is null) continue; + var local = Vector3.Transform(worldSphereCenter, cand.InverseWorldTransform); + if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local)) + return candId; // interior-wins, stop (pseudo_c:308819) + } + else if (outdoorResult == 0u) + { + // Outdoor candidate — CLandCell::point_in_cell is the XY-column the + // sphere is over (acdream landcells have no BSP point_in_cell; the + // documented adaptation). Record as the running result but DO NOT + // break — an interior cell later in the array can still win. + int gx = (int)(worldSphereCenter.X / 24f); + int gy = (int)(worldSphereCenter.Y / 24f); + if (gx >= 0 && gx < 8 && gy >= 0 && gy < 8) + { + uint outdoorId = lbPrefix | (uint)(gx * 8 + gy + 1); + if (candId == outdoorId) + outdoorResult = candId; + } } } - // 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 - // outdoor landcell (CLandCell::point_in_cell = the XY-column the sphere is over). This is - // the half of find_cell_list acdream had not ported (it skipped outdoor candidates), and - // it is what lets the SWEPT pick transition indoor<->outdoor without a static re-derive. - uint lbPrefix = currentCellId & 0xFFFF0000u; - foreach (uint candId in candidates) - { - if ((candId & 0xFFFFu) < 0x0100u) continue; // interior pass only - var cand = cache.GetCellStruct(candId); - if (cand?.CellBSP?.Root is null) continue; - var local = Vector3.Transform(worldSphereCenter, cand.InverseWorldTransform); - if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local)) - return candId; // interior-wins, stop - } - // No interior cell contains the center → outdoor landcell point_in_cell (XY-column). - // worldSphereCenter is landblock-local (A6.P4 convention); the cell index mirrors - // AddAllOutsideCells' grid math (CellSize=24, low = gx*8 + gy + 1). - { - int gx = (int)(worldSphereCenter.X / 24f); - int gy = (int)(worldSphereCenter.Y / 24f); - if (gx >= 0 && gx < 8 && gy >= 0 && gy < 8) - { - uint outdoorId = lbPrefix | (uint)(gx * 8 + gy + 1); - if (candidates.Contains(outdoorId)) - return outdoorId; - } - } - return currentCellId; // nothing contained the center — stay + // No interior cell contained the centre. Return the outdoor XY-column cell if + // it was a candidate, else stay on the current cell (retail leaves *result + // null → caller keeps curr_cell). + return outdoorResult != 0u ? outdoorResult : currentCellId; } private static int EffectiveSphereCount(IReadOnlyList worldSpheres, int numSpheres) diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs index a08eae6..130a3c1 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs @@ -202,4 +202,51 @@ public class CellTransitFindCellSetTests Assert.Contains(otherCellId, cellSet); Assert.Equal(currentCellId, containing); // retail current-cell-first hysteresis } + + // The ordered-CELLARRAY contract: FindCellSet returns the candidate set in + // retail add-order with the CURRENT cell at index 0 (retail add_cell @308766). + // This is the invariant the verbatim pick relies on; the unordered HashSet + // could not guarantee it. + [Fact] + public void FindCellSet_CurrentCellIsFirstInTheSet() + { + var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0); + var cellBT = Matrix4x4.CreateTranslation(new Vector3(5f, 0f, 0f)); + Matrix4x4.Invert(cellBT, out var cellBInv); + var cellB = new CellPhysics + { + WorldTransform = cellBT, + InverseWorldTransform = cellBInv, + Resolved = new Dictionary(), + CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf } }, + }; + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40100u, cellA); + cache.RegisterCellStructForTest(0xA9B40101u, cellB); + + // Straddle the portal plane so both cells are in the set. + var sphereCenter = new Vector3(2.0f, 0f, 2.5f); + + CellTransit.FindCellSet(cache, sphereCenter, 0.5f, 0xA9B40100u, out var cellSet); + Assert.Equal(0xA9B40100u, cellSet.First()); // current cell at index 0 + } + + // Interior-wins over the outdoor fallback: while an interior cell still + // contains the centre, it wins even though the exit portal also added the + // outdoor landcell to the set (retail interior-wins-break, pc:308814-308819). + [Fact] + public void IndoorWithExitPortal_InteriorWinsWhileItContainsCentre() + { + // Interior cell at the landblock origin with an exit portal at local x=2.5; + // Leaf BSP contains any point. Centre at local (0,12,2.5) is INSIDE the cell + // and NOT across the exit plane, so interior must win even though the head + // sphere / exit logic may add the outdoor landcell. + var exitCell = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0xFFFF, flags: 0); + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40100u, exitCell); + + var sphereCenter = new Vector3(0f, 12f, 2.5f); + uint containing = CellTransit.FindCellSet(cache, sphereCenter, 0.5f, 0xA9B40100u, out _); + Assert.Equal(0xA9B40100u, containing); // interior-wins, not the outdoor landcell + } }