diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index bdacceb..6629f94 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -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; } } }