diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs
index 7027b6a5..60dac924 100644
--- a/src/AcDream.Core/Physics/CellTransit.cs
+++ b/src/AcDream.Core/Physics/CellTransit.cs
@@ -32,6 +32,13 @@ public static class CellTransit
///
private const float EPSILON = 0.02f;
+ ///
+ /// Retail F_EPSILON (acclient.exe data @ 007c8c70, 0.000199999995f) —
+ /// the pad added to the sphere radius in the exterior-portal straddle test
+ /// (fadd [ecx+4] at 0052c8eb, #112 rider live-binary read 2026-06-10).
+ ///
+ private const float FEpsilon = 0.000199999995f;
+
///
/// Indoor portal-neighbour expansion. For each portal of
/// , test whether the sphere overlaps
@@ -51,6 +58,20 @@ public static class CellTransit
float sphereRadius,
ICollection candidates,
out bool exitOutside)
+ => FindTransitCellsSphere(
+ cache, currentCell, currentCellId, worldSphereCenter, sphereRadius,
+ candidates, out exitOutside, out _);
+
+ ///
+ public static void FindTransitCellsSphere(
+ PhysicsDataCache cache,
+ CellPhysics currentCell,
+ uint currentCellId,
+ Vector3 worldSphereCenter,
+ float sphereRadius,
+ ICollection candidates,
+ out bool exitOutside,
+ out bool hasExitPortal)
{
var spheres = new[]
{
@@ -63,7 +84,7 @@ public static class CellTransit
FindTransitCellsSphere(
cache, currentCell, currentCellId,
- spheres, spheres.Length, candidates, out exitOutside);
+ spheres, spheres.Length, candidates, out exitOutside, out hasExitPortal);
}
///
@@ -71,6 +92,18 @@ public static class CellTransit
/// pass sphere_path.num_sphere and sphere_path.global_sphere.
/// Any sphere can trigger a portal neighbor or outdoor exit.
///
+ /// RETAIL semantics (live-binary verified
+ /// 2026-06-10, #112 rider): true iff some path sphere STRADDLES one of this
+ /// cell's exterior portal polygon planes — |dist| < radius + EPSILON.
+ /// This is the only condition under which retail's
+ /// CEnvCell::find_transit_cells calls add_all_outside_cells
+ /// (acclient.exe 0052c8e5-0052c92d straddle test, 0052c9d2-0052c9f0 gate;
+ /// pseudo-C :310070-310120). Drives the membership pick's outdoor branch.
+ /// Topology-only: this cell has at least one
+ /// exterior (0xFFFF) portal, regardless of sphere position. NOT retail —
+ /// kept for the A6.P5 collision cell-set widening (outdoor-registered door
+ /// entities must stay findable from indoor cells until the A6.P4 per-cell
+ /// shadow architecture ships; see the exterior-portal branch comment).
public static void FindTransitCellsSphere(
PhysicsDataCache cache,
CellPhysics currentCell,
@@ -78,9 +111,11 @@ public static class CellTransit
IReadOnlyList worldSpheres,
int numSpheres,
ICollection candidates,
- out bool exitOutside)
+ out bool exitOutside,
+ out bool hasExitPortal)
{
exitOutside = false;
+ hasExitPortal = false;
uint lbPrefix = currentCellId & 0xFFFF0000u;
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
@@ -94,33 +129,50 @@ public static class CellTransit
if (portal.OtherCellId == 0xFFFF)
{
- // A6.P5 (2026-05-25): exit portals add outdoor cells
- // UNCONDITIONALLY, by topology — not by sphere-plane overlap.
+ hasExitPortal = true;
+
+ // #112 rider (2026-06-10): retail straddle gate, RESTORED and
+ // verified against the LIVE 2013 binary (cdb attach, function
+ // 0052c820; x87 decode at 0052c8e5-0052c92d):
//
- // 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.
+ // pad = sphere.radius + F_EPSILON (fadd [ecx+4])
+ // dist = dot(localCenter, portalPlane.n) + d (cell-local)
+ // flag |= (dist > -pad) && (dist < +pad) (fcompp/test ah,41h
+ // + fcomp/test ah,5/jp)
//
- // 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_...).
+ // add_all_outside_cells fires IFF flag (0052c9d6 je → skip). No
+ // portal_side / exact_match in this branch — BN's pseudo-C
+ // invented those (feedback_bn_decomp_field_names).
//
- // 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;
+ // History: this gate existed pre-A6.P5 and was removed
+ // 2026-05-25 citing the CALLER (find_cell_list :308775-:308785
+ // walks every array cell unconditionally — true, but each
+ // callee still applies its own straddle gate). The A6.P5
+ // symptom it fixed (outdoor-registered cottage DOORS invisible
+ // to collision from indoor cells) is really the missing
+ // per-cell shadow_object_list (#99/A6.P4): retail finds those
+ // doors via shadow lists, not via outdoor transit cells. Until
+ // A6.P4 ships, BuildCellSetAndPickContaining keeps widening the
+ // COLLISION cell set on hasExitPortal — but the membership PICK
+ // gates its outdoor branch on this retail flag, which is what
+ // keeps deep-interior containment gaps on curr_cell (retail
+ // keep-curr) instead of demoting to outdoor (#112).
+ if (!exitOutside)
+ {
+ for (int i = 0; i < sphereCount; i++)
+ {
+ var sphere = worldSpheres[i];
+ float pad = sphere.Radius + FEpsilon;
+ var localCenter = Vector3.Transform(
+ sphere.Origin, currentCell.InverseWorldTransform);
+ float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D;
+ if (dist > -pad && dist < pad)
+ {
+ exitOutside = true;
+ break;
+ }
+ }
+ }
continue;
}
@@ -510,9 +562,14 @@ public static class CellTransit
// #106: the current block's world origin converts the world-frame sphere
// coords into retail's block-local frame for the LandDefs lcoord math.
// Unregistered terrain (tests; pre-stream) falls back to Vector3.Zero —
- // the legacy anchor-block assumption (world frame == block-local frame).
+ // the legacy anchor-frame assumption (world frame == block-local frame).
cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin);
+ // #112 rider: outdoor candidates may win the pick only when retail would
+ // have admitted them — outdoor seeds always; indoor seeds only when a
+ // sphere straddled an exterior portal plane during the BFS (set below).
+ bool outdoorPickAllowed = currentLow < 0x0100u;
+
if (currentLow >= 0x0100u)
{
// Indoor seed: the CURRENT cell is added at INDEX 0 (retail
@@ -537,14 +594,24 @@ public static class CellTransit
FindTransitCellsSphere(
cache, cell, cellId, worldSpheres, sphereCount,
- candidates, out bool exitOutside);
+ candidates, out bool exitOutsideStraddle, out bool hasExitPortal);
- // 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)
+ // #112 rider (2026-06-10): the retail straddle flag (live-binary
+ // verified — see FindTransitCellsSphere) gates the PICK's outdoor
+ // branch below. Retail only ever has outdoor cells in this array
+ // when a path sphere straddles an exterior portal plane.
+ outdoorPickAllowed |= exitOutsideStraddle;
+
+ // A6.P5 (kept, NARROWED to the collision cell SET): the first
+ // exit-portal cell triggers the outdoor neighbourhood add once, by
+ // TOPOLOGY — wider than retail. This keeps outdoor-registered door
+ // entities findable from indoor cells (the 2026-05-25 door capture)
+ // until #99/A6.P4 ships per-cell shadow lists; the pick no longer
+ // consumes these cells unless the retail flag fired, so membership
+ // matches retail in both regimes. Appended AFTER the interior cells,
+ // matching retail order (add_all_outside_cells at the end,
+ // pseudo_c:310120) — interior-wins is preserved.
+ if (hasExitPortal && !outdoorAdded)
{
AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates);
outdoorAdded = true;
@@ -605,12 +672,16 @@ public static class CellTransit
if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local))
return candId; // interior-wins, stop (pseudo_c:308819)
}
- else if (outdoorResult == 0u && containingOutdoorId != 0u)
+ else if (outdoorResult == 0u && containingOutdoorId != 0u && outdoorPickAllowed)
{
// 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.
+ // #112 rider: gated on outdoorPickAllowed — retail's array only
+ // contains outdoor cells when a sphere straddled an exterior portal
+ // plane (live-binary verified); ours may also contain them via the
+ // A6.P5 collision widening, which the pick must ignore.
if (candId == containingOutdoorId)
outdoorResult = candId;
}
diff --git a/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs b/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs
index 32399d34..7dd71341 100644
--- a/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs
+++ b/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter;
@@ -37,7 +38,7 @@ public sealed class Issue112MembershipTests
}
[Fact]
- public void A9B3CottageGap_IndoorSeed_DemotesViaOutdoorCandidates_DocumentsResidual()
+ public void A9B3CottageGap_AtDoorway_StraddlesExitPlane_DemotesRetailFaithfully()
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
@@ -51,19 +52,73 @@ public sealed class Issue112MembershipTests
uint picked = CellTransit.FindCellList(cache, gap, FootRadius, 0xA9B30104u);
_out.WriteLine($"pick(seed 0x104) at gap -> 0x{picked:X8}");
- // DOCUMENTS THE #112 RESIDUAL (flips loudly when fixed): the gap sits
- // in the doorway region, so the BFS from 0x104 reaches the exterior
- // portal and outdoor cells enter the candidate array → the NORMAL
- // outdoorResult path demotes (not the removed escape hatch — its
- // removal fixed the deep-room stranding; re-promotion now happens at
- // the doorway cells on the way back in). Open question for the fix:
- // retail's CEnvCell::find_transit_cells gate for add_all_outside_cells
- // (pc:317499 region) — if it requires sphere proximity to the exterior
- // portal POLYGON (not just graph reachability), this demote disappears
- // and the assert below should become Assert.Equal(0xA9B30104u, ...).
+ // RESOLVED 2026-06-10 (#112 rider, live-binary oracle): retail's
+ // CEnvCell::find_transit_cells admits outdoor cells IFF a path sphere
+ // STRADDLES an exterior portal's plane (|dist| < radius + F_EPSILON;
+ // acclient.exe 0052c8e5-0052c9f0). The gap point sits 0.23 m from
+ // 0x104's exit-door plane (x=184.684) with foot radius 0.48 — it
+ // STRADDLES, so retail admits the outdoor column and demotes here too.
+ // This at-doorway demote is RETAIL-FAITHFUL, not a divergence; it
+ // self-heals one step inward via doorway re-promotion. The former
+ // "DocumentsResidual" framing is closed — see the deep-gap test below
+ // for the behavior that DID change with the straddle gate.
Assert.Equal(0xA9B3003Cu, picked);
}
+ [Fact]
+ public void A9B3Cottage_GapBeyondStraddleDistance_KeepsCurrCell_RetailGate()
+ {
+ var datDir = ConformanceDats.ResolveDatDir();
+ if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ var cache = LoadLandblockInteriors(dats, 0xA9B30000u);
+
+ // A no-cell point past the straddle window: 0.66 m beyond 0x104's
+ // exit-door plane (x=184.684 + 0.48 + 0.18), still in no interior cell
+ // (inside the shell-wall band). Pre-gate, the A6.P5 topology widening
+ // let the outdoor column WIN the pick here → outdoor demote deep in a
+ // containment gap (#112's transparent-interior shape). Retail keeps
+ // curr_cell: no sphere straddles any exterior portal plane, so the
+ // outdoor cells never become pick candidates (live-binary verified).
+ var deepGap = new Vector3(185.345f, 82.464f, 116.48f);
+
+ uint picked = CellTransit.FindCellList(cache, deepGap, FootRadius, 0xA9B30104u);
+ _out.WriteLine($"pick(seed 0x104) at deep gap -> 0x{picked:X8}");
+ Assert.Equal(0xA9B30104u, picked);
+ }
+
+ [Fact]
+ public void FindTransitCellsSphere_ExitPortalStraddleGate_MatchesRetail()
+ {
+ var datDir = ConformanceDats.ResolveDatDir();
+ if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ var cache = LoadLandblockInteriors(dats, 0xA9B30000u);
+
+ // Cottage north room 0x102 has an exterior door at x=186 (portal poly
+ // plane n=(±1,0,0), |d|=186 — dat dump 2026-06-10). Function-level pin
+ // of the retail gate semantics on real dat geometry:
+ var cell102 = cache.GetCellStruct(0xA9B30102u)!;
+
+ // (a) Deep inside the room, 3 m from the door plane: the cell HAS an
+ // exterior portal (topology flag) but no straddle → no outdoor
+ // admission flag (retail: var_44 stays 0, add_all_outside skipped).
+ var farCandidates = new List();
+ CellTransit.FindTransitCellsSphere(
+ cache, cell102, 0xA9B30102u, new Vector3(183.0f, 86.5f, 117.0f),
+ FootRadius, farCandidates, out bool farStraddle, out bool farHasExit);
+ Assert.True(farHasExit);
+ Assert.False(farStraddle);
+
+ // (b) At the door plane (0.30 m away < 0.48 radius): straddle fires.
+ var nearCandidates = new List();
+ CellTransit.FindTransitCellsSphere(
+ cache, cell102, 0xA9B30102u, new Vector3(185.70f, 85.5f, 117.0f),
+ FootRadius, nearCandidates, out bool nearStraddle, out bool nearHasExit);
+ Assert.True(nearHasExit);
+ Assert.True(nearStraddle);
+ }
+
[Fact]
public void ThresholdCottage_AdjacentClaim_LaterallyRecovers_ViaStabGraph()
{
diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs
index d6bffb22..bcb4bc64 100644
--- a/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs
@@ -212,7 +212,7 @@ public class CellTransitFindTransitCellsSphereTests
CellTransit.FindTransitCellsSphere(
cache, exitCell, currentCellId: 0xA9B40100u,
- spheres, spheres.Length, candidates, out bool exitOutside);
+ spheres, spheres.Length, candidates, out bool exitOutside, out _);
Assert.True(exitOutside);
}