feat(core): Phase W — faithful find_cell_list membership (interior-wins pick + swept determination, drop static :1947)

Change A (TransitionTypes.FindEnvCollisions:~1947): replace the unconditional
static ResolveCellId re-derive with the SWEPT find_cell_list pick via
CellTransit.FindCellSet. When DataCache is available (always in production),
the swept pick runs and resolves the containing cell from the portal-graph
candidate set. When DataCache is null (test engines without a cell registry),
the old ResolveCellId fallback is preserved to keep PhysicsEngineTests green.

Change B (CellTransit.BuildCellSetAndPickContaining): replace the containment
loop that silently skipped all outdoor candidates (CellBSP=null) with the
retail CObjCell::find_cell_list interior-wins pick (pseudo_c:308788-308819):
interior EnvCells win first; if no interior cell contains the center, fall
to the outdoor XY-grid column (CLandCell::point_in_cell equivalent). This is
the missing half of find_cell_list that caused the 0xA9B40170↔0xA9B40031
doorway cell-strobe — the swept pick previously always returned currentCellId
for outdoor candidates, letting the static re-derive at :1947 strobe on every
tick from a different result.

DoorwayMembershipReplayTests: two facts, loads doorway-capture.jsonl (364K records,
strobing live run), filters to Y∈[15.5,17.5] seam zone (57 records), verifies
FindCellSet produces exactly 1 transition (enter indoor → stay outdoors) with
zero A→B→A ping-pong across the full window. Second test verifies outdoor-seed
records round-trip correctly via the XY-grid formula. Both pass.

LiveCompare_FirstCap_FixClosesCottageFloorCap: still passes (issue #98 gate intact).
Full Core suite: 15 failures (within documented flaky baseline of 14–19;
all 15 are pre-existing static-leak/document-the-bug tests, zero new regressions
in cell/transit/BSP/physics classes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-02 14:53:30 +02:00
parent ed00719cf4
commit 59f3a1380d
3 changed files with 386 additions and 10 deletions

View file

@ -519,22 +519,36 @@ public static class CellTransit
PhysicsDiagnostics.LogCellSetBuild(currentCellId, worldSphereCenter, candidates);
}
// Containment test: for each candidate, transform worldSphereCenter to
// local and test PointInsideCellBsp.
// 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)
{
return candId;
uint outdoorId = lbPrefix | (uint)(gx * 8 + gy + 1);
if (candidates.Contains(outdoorId))
return outdoorId;
}
}
// No cell contained the sphere center. Stay in the input cell.
return currentCellId;
return currentCellId; // nothing contained the center — stay
}
private static int EffectiveSphereCount(IReadOnlyList<Sphere> worldSpheres, int numSpheres)

View file

@ -1944,9 +1944,28 @@ public sealed class Transition
Vector3 footCenter = sp.GlobalSphere[0].Origin;
float sphereRadius = sp.GlobalSphere[0].Radius;
uint resolvedOutdoorCellId = engine.ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId);
if (resolvedOutdoorCellId != sp.CheckCellId)
sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId);
// Phase W: cell membership comes from the SWEPT find_cell_list pick (retail
// CObjCell::find_cell_list), not a static re-derive. FindCellSet builds candidates anchored
// to the current cell (interior neighbors + outside cells via the exit portal + building
// re-entry cells) and picks the containing cell interior-wins. The commit to sp.CurCellId
// is gated by ValidateTransition (accept-on-move), so a push-back can't flip the cell.
//
// DataCache-null fallback: PhysicsEngineTests use engines without a DataCache (no cell
// registry). FindCellSet requires a cache, so we fall back to the old ResolveCellId
// outdoor re-derive in that case. In production DataCache is always set.
if (engine.DataCache is not null)
{
uint sweptCellId = CellTransit.FindCellSet(
engine.DataCache, sp.GlobalSphere, sp.NumSphere, sp.CheckCellId, out _);
if (sweptCellId != sp.CheckCellId)
sp.SetCheckPos(sp.CheckPos, sweptCellId);
}
else
{
uint resolvedOutdoorCellId = engine.ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId);
if (resolvedOutdoorCellId != sp.CheckCellId)
sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId);
}
// ── Indoor cell BSP collision ────────────────────────────────────
// If the player is in an indoor cell (low 16 bits >= 0x0100),