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:
parent
bc56545634
commit
22a184ca68
2 changed files with 110 additions and 90 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue