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)
{
// Exit portal. Any path sphere straddling the plane triggers
// the outdoor cell expansion.
for (int i = 0; i < sphereCount; i++)
{
var sphere = worldSpheres[i];
float rad = sphere.Radius + EPSILON;
var localCenter = Vector3.Transform(
sphere.Origin, currentCell.InverseWorldTransform);
float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D;
bool hit = dist > -rad && dist < rad;
if (hit)
{
exitOutside = true;
break;
}
}
// A6.P5 (2026-05-25): exit portals add outdoor cells
// UNCONDITIONALLY, by topology — not by sphere-plane overlap.
//
// Retail's CObjCell::find_cell_list (acclient_2013_pseudo_c.txt
// :308742-308869) walks vtable[0x80] on every cell already in
// the array and adds reachable cells without testing the
// sphere against each portal plane. The straddle check we
// had here gated outdoor inclusion on the sphere physically
// overlapping the EXIT portal — which fails to fire when:
// a) the sphere is in a SIBLING indoor cell that BFS-
// expanded to this one (sphere is geographically near
// the doorway region, just not at THIS cell's exit
// portal plane); OR
// 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;
}
@ -441,6 +452,7 @@ public static class CellTransit
pending.Enqueue(currentCellId);
visited.Add(currentCellId);
int maxIterations = 16; // hard cap; portal graphs are small
bool outdoorAdded = false;
while (pending.Count > 0 && maxIterations-- > 0)
{
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);
outdoorAdded = true;
}
}
}