fix(phys): A6.P5 — unconditional outdoor expansion in CellTransit BFS

Retail's CObjCell::find_cell_list at acclient_2013_pseudo_c.txt:308742-
308869 walks vtable[0x80] on every cell in the array and adds portal-
reachable cells unconditionally — without testing each portal plane
against the sphere. Our exit-portal branch in FindTransitCellsSphere
gated outdoor inclusion on sphere-plane overlap (exitOutside fired
only when the sphere physically straddled the exit portal plane).

That gate produced the cottage-door over-penetration bug verified in
A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell: BFS from
indoor cell 0xA9B4013F expanded to 0xA9B40150 (which has an exit
portal) but the sphere — in 0xA9B4013F's volume — wasn't at 0xA9B40150's
exit portal plane, so exitOutside stayed false and the door's outdoor
cell 0xA9B40029 wasn't added to the cellSet. The cell-crossing tick's
collision query missed the door and the sphere committed 0.27 m INTO
the slab.

Fix: exit portals contribute exitOutside=true by topology
(OtherCellId == 0xFFFFu), not by sphere overlap. AddAllOutsideCells
is deduped to once per BFS so the radial pattern is added exactly
once when any BFS-visited cell has an exit portal.

Conformance: A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell
now passes. A6P5_BuildCellSetFromAlcove_AlsoReachesDoorOutdoorCell
(regression guard for the previously-sometimes-working case) stays
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-25 12:53:31 +02:00
parent 2a890e6bde
commit 3b1ae83931

View file

@ -94,22 +94,33 @@ public static class CellTransit
if (portal.OtherCellId == 0xFFFF) if (portal.OtherCellId == 0xFFFF)
{ {
// Exit portal. Any path sphere straddling the plane triggers // A6.P5 (2026-05-25): exit portals add outdoor cells
// the outdoor cell expansion. // UNCONDITIONALLY, by topology — not by sphere-plane overlap.
for (int i = 0; i < sphereCount; i++) //
{ // Retail's CObjCell::find_cell_list (acclient_2013_pseudo_c.txt
var sphere = worldSpheres[i]; // :308742-308869) walks vtable[0x80] on every cell already in
float rad = sphere.Radius + EPSILON; // the array and adds reachable cells without testing the
var localCenter = Vector3.Transform( // sphere against each portal plane. The straddle check we
sphere.Origin, currentCell.InverseWorldTransform); // had here gated outdoor inclusion on the sphere physically
float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D; // overlapping the EXIT portal — which fails to fire when:
bool hit = dist > -rad && dist < rad; // a) the sphere is in a SIBLING indoor cell that BFS-
if (hit) // expanded to this one (sphere is geographically near
{ // the doorway region, just not at THIS cell's exit
exitOutside = true; // portal plane); OR
break; // b) the per-tick target moves the sphere across the
} // portal plane on one tick but not the next, producing
} // intermittent visibility from the same position.
//
// Pre-fix bug: cottage doors at outdoor cells were invisible
// from indoor cells during cell-crossing substeps (live
// capture 2026-05-25; over-penetration test in
// CellTransitTests.A6P5_BuildCellSetFromIndoorStart_...).
//
// Post-fix: any cell visited by BFS that has at least one
// exit portal contributes exitOutside=true regardless of
// sphere position. AddAllOutsideCells fires once per BFS
// (deduped in BuildCellSetAndPickContaining).
exitOutside = true;
continue; continue;
} }
@ -441,6 +452,7 @@ public static class CellTransit
pending.Enqueue(currentCellId); pending.Enqueue(currentCellId);
visited.Add(currentCellId); visited.Add(currentCellId);
int maxIterations = 16; // hard cap; portal graphs are small int maxIterations = 16; // hard cap; portal graphs are small
bool outdoorAdded = false;
while (pending.Count > 0 && maxIterations-- > 0) while (pending.Count > 0 && maxIterations-- > 0)
{ {
uint cellId = pending.Dequeue(); uint cellId = pending.Dequeue();
@ -461,10 +473,18 @@ public static class CellTransit
} }
} }
if (exitOutside) // 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)
{ {
// Add neighbour outdoor cells too.
AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, candidates); AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, candidates);
outdoorAdded = true;
} }
} }
} }