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); }