fix(phys): #112 residual - retail straddle gate for outdoor-cell admission (live-binary verified)

The oracle read the #112 residual was waiting on, settled against the
LIVE 2013 client (cdb attach, CEnvCell::find_transit_cells @ 0052c820;
BN pseudo-C was ambiguous and partly wrong per
feedback_bn_decomp_field_names - it invented portal_side tests in this
branch): retail admits outdoor transit cells from an indoor cell IFF a
path sphere STRADDLES an exterior portal polygon plane,
|dist| < radius + F_EPSILON(0.000199999995, @ 007c8c70). The flag at
[esp+18h] (set 0052c925, x87 decode fcompp/test ah,41h +
fcomp/test ah,5/jp) gates the add_all_outside_cells call (0052c9d6 je).
Graph reachability alone NEVER admits outdoor cells in retail.

Port (CellTransit):
- FindTransitCellsSphere: exitOutside now carries the retail straddle
  semantics; new hasExitPortal out carries the old topology-only flag.
- BuildCellSetAndPickContaining: the collision cell SET keeps the A6.P5
  topology widening on hasExitPortal (outdoor-registered doors must stay
  findable from indoor cells until #99/A6.P4 ships per-cell shadow
  lists - the 2026-05-25 door capture scenario), but the membership
  PICK's outdoor branch is gated on the retail flag. Membership is now
  retail-identical in both regimes: straddle -> outdoor candidates valid;
  no straddle -> outdoor ignored -> retail keep-curr. This is what stops
  deep-interior containment gaps in ANY house from demoting to outdoor
  (the #112 transparent-interior shape) - the systemic protection the
  user asked for, without house-by-house verification.

The at-doorway A9B3 gap demote is RETAIL-FAITHFUL (gap point is 0.23m
from 0x104s door plane < 0.48 foot radius -> retail straddles + demotes
+ self-heals inward): DocumentsResidual renamed to
...DemotesRetailFaithfully, expectation unchanged. New conformance pins:
deep-gap keep-curr (A9B3Cottage_GapBeyondStraddleDistance_KeepsCurrCell)
+ function-level gate semantics on real dat geometry
(FindTransitCellsSphere_ExitPortalStraddleGate_MatchesRetail).

Tests: Core 1391 green (+2) / App 224 / UI 420 / Net 294; pre-existing
4 #99-era failures unchanged; P1 membership goldens + A6.P5 door-set
tests explicitly green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-10 16:52:24 +02:00
parent 927fd8fde2
commit 414c3deaf4
3 changed files with 173 additions and 47 deletions

View file

@ -32,6 +32,13 @@ public static class CellTransit
/// </summary> /// </summary>
private const float EPSILON = 0.02f; private const float EPSILON = 0.02f;
/// <summary>
/// Retail <c>F_EPSILON</c> (acclient.exe data @ 007c8c70, 0.000199999995f) —
/// the pad added to the sphere radius in the exterior-portal straddle test
/// (<c>fadd [ecx+4]</c> at 0052c8eb, #112 rider live-binary read 2026-06-10).
/// </summary>
private const float FEpsilon = 0.000199999995f;
/// <summary> /// <summary>
/// Indoor portal-neighbour expansion. For each portal of /// Indoor portal-neighbour expansion. For each portal of
/// <paramref name="currentCell"/>, test whether the sphere overlaps /// <paramref name="currentCell"/>, test whether the sphere overlaps
@ -51,6 +58,20 @@ public static class CellTransit
float sphereRadius, float sphereRadius,
ICollection<uint> candidates, ICollection<uint> candidates,
out bool exitOutside) out bool exitOutside)
=> FindTransitCellsSphere(
cache, currentCell, currentCellId, worldSphereCenter, sphereRadius,
candidates, out exitOutside, out _);
/// <inheritdoc cref="FindTransitCellsSphere(PhysicsDataCache, CellPhysics, uint, IReadOnlyList{Sphere}, int, ICollection{uint}, out bool, out bool)"/>
public static void FindTransitCellsSphere(
PhysicsDataCache cache,
CellPhysics currentCell,
uint currentCellId,
Vector3 worldSphereCenter,
float sphereRadius,
ICollection<uint> candidates,
out bool exitOutside,
out bool hasExitPortal)
{ {
var spheres = new[] var spheres = new[]
{ {
@ -63,7 +84,7 @@ public static class CellTransit
FindTransitCellsSphere( FindTransitCellsSphere(
cache, currentCell, currentCellId, cache, currentCell, currentCellId,
spheres, spheres.Length, candidates, out exitOutside); spheres, spheres.Length, candidates, out exitOutside, out hasExitPortal);
} }
/// <summary> /// <summary>
@ -71,6 +92,18 @@ public static class CellTransit
/// pass <c>sphere_path.num_sphere</c> and <c>sphere_path.global_sphere</c>. /// pass <c>sphere_path.num_sphere</c> and <c>sphere_path.global_sphere</c>.
/// Any sphere can trigger a portal neighbor or outdoor exit. /// Any sphere can trigger a portal neighbor or outdoor exit.
/// </summary> /// </summary>
/// <param name="exitOutside">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 — <c>|dist| &lt; radius + EPSILON</c>.
/// This is the only condition under which retail's
/// <c>CEnvCell::find_transit_cells</c> calls <c>add_all_outside_cells</c>
/// (acclient.exe 0052c8e5-0052c92d straddle test, 0052c9d2-0052c9f0 gate;
/// pseudo-C :310070-310120). Drives the membership pick's outdoor branch.</param>
/// <param name="hasExitPortal">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).</param>
public static void FindTransitCellsSphere( public static void FindTransitCellsSphere(
PhysicsDataCache cache, PhysicsDataCache cache,
CellPhysics currentCell, CellPhysics currentCell,
@ -78,9 +111,11 @@ public static class CellTransit
IReadOnlyList<Sphere> worldSpheres, IReadOnlyList<Sphere> worldSpheres,
int numSpheres, int numSpheres,
ICollection<uint> candidates, ICollection<uint> candidates,
out bool exitOutside) out bool exitOutside,
out bool hasExitPortal)
{ {
exitOutside = false; exitOutside = false;
hasExitPortal = false;
uint lbPrefix = currentCellId & 0xFFFF0000u; uint lbPrefix = currentCellId & 0xFFFF0000u;
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres); int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
@ -94,33 +129,50 @@ public static class CellTransit
if (portal.OtherCellId == 0xFFFF) if (portal.OtherCellId == 0xFFFF)
{ {
// A6.P5 (2026-05-25): exit portals add outdoor cells hasExitPortal = true;
// UNCONDITIONALLY, by topology — not by sphere-plane overlap.
// #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 // pad = sphere.radius + F_EPSILON (fadd [ecx+4])
// :308742-308869) walks vtable[0x80] on every cell already in // dist = dot(localCenter, portalPlane.n) + d (cell-local)
// the array and adds reachable cells without testing the // flag |= (dist > -pad) && (dist < +pad) (fcompp/test ah,41h
// sphere against each portal plane. The straddle check we // + fcomp/test ah,5/jp)
// 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 // add_all_outside_cells fires IFF flag (0052c9d6 je → skip). No
// from indoor cells during cell-crossing substeps (live // portal_side / exact_match in this branch — BN's pseudo-C
// capture 2026-05-25; over-penetration test in // invented those (feedback_bn_decomp_field_names).
// CellTransitTests.A6P5_BuildCellSetFromIndoorStart_...).
// //
// Post-fix: any cell visited by BFS that has at least one // History: this gate existed pre-A6.P5 and was removed
// exit portal contributes exitOutside=true regardless of // 2026-05-25 citing the CALLER (find_cell_list :308775-:308785
// sphere position. AddAllOutsideCells fires once per BFS // walks every array cell unconditionally — true, but each
// (deduped in BuildCellSetAndPickContaining). // callee still applies its own straddle gate). The A6.P5
exitOutside = true; // 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; continue;
} }
@ -510,9 +562,14 @@ public static class CellTransit
// #106: the current block's world origin converts the world-frame sphere // #106: the current block's world origin converts the world-frame sphere
// coords into retail's block-local frame for the LandDefs lcoord math. // coords into retail's block-local frame for the LandDefs lcoord math.
// Unregistered terrain (tests; pre-stream) falls back to Vector3.Zero — // 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); 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) if (currentLow >= 0x0100u)
{ {
// Indoor seed: the CURRENT cell is added at INDEX 0 (retail // Indoor seed: the CURRENT cell is added at INDEX 0 (retail
@ -537,14 +594,24 @@ public static class CellTransit
FindTransitCellsSphere( FindTransitCellsSphere(
cache, cell, cellId, worldSpheres, sphereCount, 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 // #112 rider (2026-06-10): the retail straddle flag (live-binary
// neighbourhood add once. Appended AFTER the interior cells, matching // verified — see FindTransitCellsSphere) gates the PICK's outdoor
// retail (CEnvCell::find_transit_cells calls add_all_outside_cells at // branch below. Retail only ever has outdoor cells in this array
// the end, pseudo_c:310120) — so interior cells precede outdoor in the // when a path sphere straddles an exterior portal plane.
// pick order and interior-wins is preserved. outdoorPickAllowed |= exitOutsideStraddle;
if (exitOutside && !outdoorAdded)
// 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); AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates);
outdoorAdded = true; outdoorAdded = true;
@ -605,12 +672,16 @@ public static class CellTransit
if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local)) if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local))
return candId; // interior-wins, stop (pseudo_c:308819) 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 // Outdoor candidate — CLandCell::point_in_cell is the XY-column the
// sphere is over (acdream landcells have no BSP point_in_cell; the // sphere is over (acdream landcells have no BSP point_in_cell; the
// documented adaptation). Record as the running result but DO NOT // documented adaptation). Record as the running result but DO NOT
// break — an interior cell later in the array can still win. // 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) if (candId == containingOutdoorId)
outdoorResult = candId; outdoorResult = candId;
} }

View file

@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using DatReaderWriter; using DatReaderWriter;
@ -37,7 +38,7 @@ public sealed class Issue112MembershipTests
} }
[Fact] [Fact]
public void A9B3CottageGap_IndoorSeed_DemotesViaOutdoorCandidates_DocumentsResidual() public void A9B3CottageGap_AtDoorway_StraddlesExitPlane_DemotesRetailFaithfully()
{ {
var datDir = ConformanceDats.ResolveDatDir(); var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; } 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); uint picked = CellTransit.FindCellList(cache, gap, FootRadius, 0xA9B30104u);
_out.WriteLine($"pick(seed 0x104) at gap -> 0x{picked:X8}"); _out.WriteLine($"pick(seed 0x104) at gap -> 0x{picked:X8}");
// DOCUMENTS THE #112 RESIDUAL (flips loudly when fixed): the gap sits // RESOLVED 2026-06-10 (#112 rider, live-binary oracle): retail's
// in the doorway region, so the BFS from 0x104 reaches the exterior // CEnvCell::find_transit_cells admits outdoor cells IFF a path sphere
// portal and outdoor cells enter the candidate array → the NORMAL // STRADDLES an exterior portal's plane (|dist| < radius + F_EPSILON;
// outdoorResult path demotes (not the removed escape hatch — its // acclient.exe 0052c8e5-0052c9f0). The gap point sits 0.23 m from
// removal fixed the deep-room stranding; re-promotion now happens at // 0x104's exit-door plane (x=184.684) with foot radius 0.48 — it
// the doorway cells on the way back in). Open question for the fix: // STRADDLES, so retail admits the outdoor column and demotes here too.
// retail's CEnvCell::find_transit_cells gate for add_all_outside_cells // This at-doorway demote is RETAIL-FAITHFUL, not a divergence; it
// (pc:317499 region) — if it requires sphere proximity to the exterior // self-heals one step inward via doorway re-promotion. The former
// portal POLYGON (not just graph reachability), this demote disappears // "DocumentsResidual" framing is closed — see the deep-gap test below
// and the assert below should become Assert.Equal(0xA9B30104u, ...). // for the behavior that DID change with the straddle gate.
Assert.Equal(0xA9B3003Cu, picked); 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<uint>();
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<uint>();
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] [Fact]
public void ThresholdCottage_AdjacentClaim_LaterallyRecovers_ViaStabGraph() public void ThresholdCottage_AdjacentClaim_LaterallyRecovers_ViaStabGraph()
{ {

View file

@ -212,7 +212,7 @@ public class CellTransitFindTransitCellsSphereTests
CellTransit.FindTransitCellsSphere( CellTransit.FindTransitCellsSphere(
cache, exitCell, currentCellId: 0xA9B40100u, cache, exitCell, currentCellId: 0xA9B40100u,
spheres, spheres.Length, candidates, out bool exitOutside); spheres, spheres.Length, candidates, out bool exitOutside, out _);
Assert.True(exitOutside); Assert.True(exitOutside);
} }