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,
int numSpheres,
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);
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<uint>();
var visited = new HashSet<uint>();
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<num_cells; i++) cells[i].find_transit_cells(...)`
// loop (pseudo_c:308775-308785). FindTransitCellsSphere APPENDS portal
// neighbours (and, on an exit portal, the outdoor landcells) to the same
// array; CellArray.Add dedups, so the walk terminates when no new cell is
// appended. Read OrderedIds[i] by index because the list grows under us.
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);
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<uint>(candidates);
var landcellSnapshot = new List<uint>(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<Sphere> worldSpheres, int numSpheres)

View file

@ -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<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
}
}