fix(physics): Stage 1 — verbatim ordered-CELLARRAY membership pick (the R1 flap)

Port CObjCell::find_cell_list (acclient_2013_pseudo_c.txt:308742) faithfully:
- build candidates into an ordered CellArray with the CURRENT cell at index 0
  (add_cell @308766);
- EXPAND via a single forward walk over the growing array, mirroring retail's
  for(i=0;i<num_cells;i++) cells[i].find_transit_cells loop (308775-308785),
  replacing the order-losing Queue/visited BFS;
- PICK in array order with interior-wins-break (308788-308825): current cell at
  index 0 wins a boundary straddle, so membership no longer ping-pongs.

Deletes the 5ca2f44 current-first pre-check (the ordered array subsumes it for every
seed). Keeps its guard test (TwoOverlappingCells_CurrentCellWinsTheStraddle) + adds
two conformance tests (current-cell-first ordering; interior-wins over outdoor
fallback). Membership net: 45 pass. Decomp finding: retail stability is emergent from
the ordered pick + carried seed, not a separate portal-crossing detector — see
docs/research/2026-06-03-cell-membership-ordered-cellarray-pseudocode.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-03 09:00:12 +02:00
parent bc56545634
commit 22a184ca68
2 changed files with 110 additions and 90 deletions

View file

@ -428,59 +428,52 @@ public static class CellTransit
IReadOnlyList<Sphere> worldSpheres, IReadOnlyList<Sphere> worldSpheres,
int numSpheres, int numSpheres,
uint currentCellId, uint currentCellId,
out HashSet<uint> candidates) out CellArray candidates)
{ {
candidates = new HashSet<uint>(); // 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); int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
if (sphereCount == 0) return currentCellId; if (sphereCount == 0) return currentCellId;
Vector3 worldSphereCenter = worldSpheres[0].Origin; Vector3 worldSphereCenter = worldSpheres[0].Origin;
float sphereRadius = worldSpheres[0].Radius; float sphereRadius = worldSpheres[0].Radius;
uint currentLow = currentCellId & 0xFFFFu; uint currentLow = currentCellId & 0xFFFFu;
uint lbPrefix = currentCellId & 0xFFFF0000u;
if (currentLow >= 0x0100u) 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); var currentCell = cache.GetCellStruct(currentCellId);
if (currentCell is null) return currentCellId; if (currentCell is null) return currentCellId;
candidates.Add(currentCellId); candidates.Add(currentCellId);
// BFS the portal graph (one hop per pass — usually 1-2 passes is enough). // EXPAND — a single forward walk over the GROWING array, mirroring
var pending = new Queue<uint>(); // retail's `for (i=0; i<num_cells; i++) cells[i].find_transit_cells(...)`
var visited = new HashSet<uint>(); // loop (pseudo_c:308775-308785). FindTransitCellsSphere APPENDS portal
pending.Enqueue(currentCellId); // neighbours (and, on an exit portal, the outdoor landcells) to the same
visited.Add(currentCellId); // array; CellArray.Add dedups, so the walk terminates when no new cell is
int maxIterations = 16; // hard cap; portal graphs are small // appended. Read OrderedIds[i] by index because the list grows under us.
bool outdoorAdded = false; bool outdoorAdded = false;
while (pending.Count > 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); var cell = cache.GetCellStruct(cellId);
if (cell is null) continue; if (cell is null) continue;
var sizeBefore = candidates.Count;
FindTransitCellsSphere( FindTransitCellsSphere(
cache, cell, cellId, worldSpheres, sphereCount, cache, cell, cellId, worldSpheres, sphereCount,
candidates, out bool exitOutside); candidates, out bool exitOutside);
if (candidates.Count > sizeBefore) // A6.P5 (kept): the first exit-portal cell triggers the outdoor
{ // neighbourhood add once. Appended AFTER the interior cells, matching
foreach (var c in candidates) // retail (CEnvCell::find_transit_cells calls add_all_outside_cells at
{ // the end, pseudo_c:310120) — so interior cells precede outdoor in the
if (visited.Add(c)) // only enqueue if NEW // pick order and interior-wins is preserved.
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.
if (exitOutside && !outdoorAdded) if (exitOutside && !outdoorAdded)
{ {
AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, candidates); AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, candidates);
@ -490,22 +483,12 @@ public static class CellTransit
} }
else else
{ {
// Outdoor seed: expand neighbour landcells AND check for building stabs // Outdoor seed: expand neighbour landcells (added first), then check each
// with portals into interior EnvCells. // 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); AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, candidates);
// For each landcell candidate, see if it carries a building stab; if so, var landcellSnapshot = new List<uint>(candidates.OrderedIds);
// 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<uint>(candidates);
foreach (uint landcellId in landcellSnapshot) foreach (uint landcellId in landcellSnapshot)
{ {
var building = cache.GetBuilding(landcellId); var building = cache.GetBuilding(landcellId);
@ -515,59 +498,49 @@ public static class CellTransit
} }
if (PhysicsDiagnostics.ProbeCellSetEnabled) if (PhysicsDiagnostics.ProbeCellSetEnabled)
{
PhysicsDiagnostics.LogCellSetBuild(currentCellId, worldSphereCenter, candidates); PhysicsDiagnostics.LogCellSetBuild(currentCellId, worldSphereCenter, candidates);
}
// Retail CObjCell::find_cell_list checks the CURRENT cell FIRST — it adds it at index 0 // THE PICK — verbatim CObjCell::find_cell_list containing-cell pick
// (add_cell, pseudo_c:308766) and the pick loop iterates from index 0 with interior-wins-break // (pseudo_c:308788-308825): iterate the array IN ORDER from index 0; for each
// (pseudo_c:308791-308819). So if the sphere center is still inside the current cell, it wins // cell, point_in_cell; set the running result on ANY containing cell;
// and the search stops: you STAY in your current cell until the center genuinely leaves it, // INTERIOR-WINS-BREAK. The current cell is at index 0, so if the sphere centre
// never flipping to an overlapping neighbour. acdream's unordered HashSet candidate set dropped // is still inside it, it wins and the search stops — the retail hysteresis.
// that ordering — once the set churns at a boundary the enumeration can surface a neighbour // (Replaces the 5ca2f44 current-first pre-check, which approximated this for
// before the current cell, producing the membership ping-pong (the R1 flap). Restore the // the indoor-current case only; the ordered array now delivers it for every
// explicit, deterministic current-cell-first test (the retail hysteresis): // seed by construction.)
if (currentLow >= 0x0100u) uint outdoorResult = 0u;
foreach (uint candId in candidates.OrderedIds)
{ {
var curCell = cache.GetCellStruct(currentCellId); if ((candId & 0xFFFFu) >= 0x0100u)
if (curCell?.CellBSP?.Root is not null)
{ {
var localCur = Vector3.Transform(worldSphereCenter, curCell.InverseWorldTransform); // Interior candidate — point_in_cell via the cell BSP (vtable[0x84]).
if (BSPQuery.PointInsideCellBsp(curCell.CellBSP.Root, localCur)) var cand = cache.GetCellStruct(candId);
return currentCellId; // still inside the current cell → stay (retail index-0) 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): // No interior cell contained the centre. Return the outdoor XY-column cell if
// INTERIOR-WINS — the first EnvCell whose point_in_cell (BSP) contains the sphere center // it was a candidate, else stay on the current cell (retail leaves *result
// wins and stops the search. Only if no interior cell contains it do we fall to the // null → caller keeps curr_cell).
// outdoor landcell (CLandCell::point_in_cell = the XY-column the sphere is over). This is return outdoorResult != 0u ? outdoorResult : currentCellId;
// 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
} }
private static int EffectiveSphereCount(IReadOnlyList<Sphere> worldSpheres, int numSpheres) private static int EffectiveSphereCount(IReadOnlyList<Sphere> worldSpheres, int numSpheres)

View file

@ -202,4 +202,51 @@ public class CellTransitFindCellSetTests
Assert.Contains(otherCellId, cellSet); Assert.Contains(otherCellId, cellSet);
Assert.Equal(currentCellId, containing); // retail current-cell-first hysteresis 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<ushort, ResolvedPolygon>(),
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
}
} }