diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index ba6678a8..00ea9aa6 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -740,72 +740,85 @@ public static class CellTransit // sphere straddled an exterior portal plane during the BFS (set below). bool outdoorPickAllowed = currentLow < 0x0100u; + // SEED (retail CObjCell::find_cell_list 0052b535-0052b56c): an indoor id + // adds exactly the current cell at INDEX 0 (the current-cell-first pick + // hysteresis that stops the flap); an outdoor id adds every landcell the + // path spheres overlap (add_all_outside_cells, 0052b53f — which also + // sets CELLARRAY.added_outside, hence outdoorAdded starts true there). + bool outdoorAdded; if (currentLow >= 0x0100u) { - // Indoor seed: the CURRENT cell is added at INDEX 0 (retail - // CObjCell::find_cell_list add_cell @ pseudo_c:308766). Index 0 is what - // makes the pick current-cell-first — the hysteresis that stops the flap. var currentCell = cache.GetCellStruct(currentCellId); if (currentCell is null) return currentCellId; candidates.Add(currentCellId); - - // EXPAND — a single forward walk over the GROWING array, mirroring - // retail's `for (i=0; i(candidates.OrderedIds); - foreach (uint landcellId in landcellSnapshot) + // THE WALK — ONE forward pass over the GROWING array for EVERY seed, + // mirroring retail's `for (i=0; ifind_transit_cells(...)` vtable dispatch (pseudo_c: + // 308775-308785 / 0052b576-0052b5ab). CellArray.Add dedups, so the walk + // terminates when no new cell is appended; read OrderedIds[i] by index + // because the list grows under us. + // + // #112 ROOT CAUSE (2026-06-12, cottage-112-capture1.log): the outdoor + // seed used to run CheckBuildingTransit over a landcell SNAPSHOT and + // stop — building-admitted entry cells were never expanded, so a player + // whose centre stood in a DEEP room (not building-portal-adjacent) + // could never be promoted from an outdoor seed: the pick kept the + // outdoor landcell while they walked the cottage interior (transparent + // interior; promotion fired only on touching portal-adjacent 0x102's + // own volume). Retail's single growing walk expands the admitted entry + // cells to the deeper rooms the spheres overlap — ported below. + for (int i = 0; i < candidates.Count; i++) + { + uint cellId = candidates.OrderedIds[i]; + + if ((cellId & 0xFFFFu) < 0x0100u) { - var building = cache.GetBuilding(landcellId); + // Landcell dispatch — CLandCell::find_transit_cells (0x00533800) + // → CSortCell::find_transit_cells (0x00534060, this->building) + // → CBuildingObj::find_building_transit_cells (0x006b5230) + // → CEnvCell::check_building_transit (0x0052c5d0): the building + // bridge admits the building's portal-adjacent ENTRY cells into + // the same growing array; the walk then expands them via the + // envcell dispatch below. + var building = cache.GetBuilding(cellId); if (building is null) continue; - CheckBuildingTransit(cache, building, worldSphereCenter, sphereRadius, candidates); + CheckBuildingTransit(cache, building, worldSpheres, sphereCount, candidates, out _); + continue; + } + + var cell = cache.GetCellStruct(cellId); + if (cell is null) continue; + + FindTransitCellsSphere( + cache, cell, cellId, worldSpheres, sphereCount, + candidates, out bool exitOutsideStraddle); + + // #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; + + // BR-7 / A6.P4 C4 (2026-06-11): outdoor cells enter the array + // on the retail STRADDLE gate — |dist| < radius + F_EPSILON + // against an exterior portal plane (CEnvCell::find_transit_cells + // 0x0052c820; gate at 0052c9d6) — replacing the A6.P5 + // hasExitPortal TOPOLOGY widening. Appended AFTER the interior + // cells, matching retail order (add_all_outside_cells at the end, + // pseudo_c:310120) — interior-wins is preserved. Once-per-walk via + // outdoorAdded = retail CELLARRAY.added_outside (0x00533630). + if (exitOutsideStraddle && !outdoorAdded) + { + AddAllOutsideCells(worldSpheres, sphereCount, currentCellId, blockOrigin, candidates); + outdoorAdded = true; } } diff --git a/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs b/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs index 7a890097..ffe398ad 100644 --- a/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs +++ b/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs @@ -120,6 +120,168 @@ public sealed class Issue112MembershipTests Assert.True(nearStraddle); } + /// + /// Register the A9B3 landblock's buildings into the cache exactly as + /// production does (GameWindow streaming drain ~6080: portals → + /// BldPortalInfo with sign-extended OtherPortalId; landcell id from the + /// building Frame.Origin via the retail row-major grid formula). The + /// fixture frame is block-local (ConformanceDats.LoadEnvCell uses + /// EnvCell.Position verbatim), so Frame.Origin needs no offset. + /// + private static void RegisterBuildings(DatCollection dats, PhysicsDataCache cache, uint lbPrefix) + { + var lbInfo = dats.Get(lbPrefix | 0xFFFEu); + Assert.NotNull(lbInfo); + foreach (var building in lbInfo!.Buildings) + { + if (building.Portals.Count == 0) continue; + var portals = new List(building.Portals.Count); + foreach (var bp in building.Portals) + portals.Add(new BldPortalInfo( + otherCellId: lbPrefix | (uint)bp.OtherCellId, + otherPortalId: unchecked((short)bp.OtherPortalId), + flags: (ushort)bp.Flags)); + var transform = + Matrix4x4.CreateFromQuaternion(building.Frame.Orientation) * + Matrix4x4.CreateTranslation(building.Frame.Origin); + int gridX = (int)(building.Frame.Origin.X / 24f); + int gridY = (int)(building.Frame.Origin.Y / 24f); + uint landcellLow = (uint)(gridX * 8 + gridY + 1); + cache.CacheBuilding(lbPrefix | landcellLow, portals, transform); + } + } + + [Fact] + public void A9B3Cottage_OutdoorSeed_TickSkippedThreshold_RecoversViaGrowingWalk() + { + // #112 ROOT CAUSE (cottage-112-capture1.log, 2026-06-12): the cottage's + // entry cell 0x104 is a 0.22 m-wide THRESHOLD band (x 184.68→184.46 at + // y≈82.2). A player RUNNING crosses it in under one physics tick, so + // the tick where the centre is inside 0x104 can simply never happen — + // the next tick's centre is already in 0x100, a DEEP room (not + // building-portal-adjacent). Retail recovers on that very tick: its + // find_cell_list runs ONE growing-array walk for EVERY seed + // (0052b576-0052b5ab, cells[i]->find_transit_cells vtable dispatch over + // the GROWING array) — the landcell's building bridge admits 0x104 + // (the sphere still overlaps the band) and the walk EXPANDS 0x104's + // portals to 0x100, where containment wins. Our outdoor branch used to + // stop at the bridge cells (no walk): the miss became ABSORBING — the + // pick kept the outdoor landcell while the player walked the whole + // interior (the transparent cottage), promoting only on touching a + // portal-adjacent cell's own volume (the captured late 0x102 flip). + 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); + RegisterBuildings(dats, cache, 0xA9B30000u); + + // One tick past the skipped threshold: centre inside 0x100 (deep), foot + // sphere still overlapping the 0x104 band (184.3 + 0.48 > 184.46). + var pastThreshold = new Vector3(184.30f, 82.20f, 116.48f); + + uint picked = CellTransit.FindCellList(cache, pastThreshold, FootRadius, 0xA9B3003Cu); + _out.WriteLine($"pick(seed 0x3C) one tick past the threshold -> 0x{picked:X8}"); + Assert.Equal(0xA9B30100u, picked); + } + + [Fact] + public void A9B3Cottage_RunSpeedEntryReplay_NeverStrandsOutdoor() + { + // The live mechanism end-to-end at run speed: replay the captured entry + // line in 16 cm ticks (≈5 m/s at 30 Hz physics). Whatever individual + // tick lands or skips the 0.22 m threshold band, the seed-evolving pick + // must end INDOOR by the time the player is inside — never the + // absorbing outdoor-deep-inside state the user sat in (transparent + // cottage until randomly touching a portal-adjacent room). + 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); + RegisterBuildings(dats, cache, 0xA9B30000u); + + // Sweep several tick phases so the threshold band is sometimes landed + // on, sometimes skipped — every phase must end indoor. + for (float phase = 0f; phase < 0.16f; phase += 0.04f) + { + uint curr = 0xA9B3003Cu; + for (float x = 191.5f - phase; x > 181.0f; x -= 0.16f) + { + float t = (191.5f - x) / 10.5f; + var p = new Vector3(x, 82.7f - t * 0.7f, 116.48f); + curr = CellTransit.FindCellList(cache, p, FootRadius, curr); + } + _out.WriteLine($"phase {phase:F2}: final curr=0x{curr & 0xFFFFu:X4}"); + Assert.True((curr & 0xFFFFu) >= 0x0100u, + $"run-speed entry (phase {phase:F2}) stranded OUTDOOR (0x{curr:X8}) inside the cottage — the #112 absorbing state"); + } + } + + [Fact] + public void Diagnostic_ReplayCapturedEntryWalk() + { + // Replays the cottage-112-capture1.log entry walk (world → A9B3-local = + // world + (0, 192)): west along y≈82 through the doorway, the stand, + // then the wander north to the observed 0x102 promotion point. Output + // only — prints per step: containing cell (direct BSP scan), the + // evolving pick, and which landcells had buildings. Names the first + // step where retail would promote vs where ours does. + 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); + RegisterBuildings(dats, cache, 0xA9B30000u); + _out.WriteLine($"buildings on landcells: {string.Join(", ", System.Linq.Enumerable.Select(cache.BuildingIds, b => $"0x{b:X8}"))}"); + + uint curr = 0xA9B3003Cu; + uint lastPrinted = 0; + void Step(Vector3 p) + { + uint contains = 0; + for (uint low = 0x0100; low <= 0x010F; low++) + { + var cand = cache.GetCellStruct(0xA9B30000u | low); + if (cand?.CellBSP?.Root is null) continue; + var local = Vector3.Transform(p, cand.InverseWorldTransform); + if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local)) { contains = 0xA9B30000u | low; break; } + } + uint picked = CellTransit.FindCellList(cache, p, FootRadius, curr); + if (picked != curr || contains != lastPrinted) + _out.WriteLine($" ({p.X:F2},{p.Y:F2}) contains=0x{contains & 0xFFFFu:X4} pick: 0x{curr & 0xFFFFu:X4} -> 0x{picked & 0xFFFFu:X4}"); + lastPrinted = contains; + curr = picked; + } + + _out.WriteLine("— leg A: west along y 82.7→82.0, x 191.5→181.0 —"); + for (float t = 0f; t <= 1f; t += 0.01f) + Step(new Vector3(191.5f - t * 10.5f, 82.7f - t * 0.7f, 116.48f)); + _out.WriteLine($"after leg A: curr=0x{curr & 0xFFFFu:X4}"); + + _out.WriteLine("— leg B: wander north (181.0,82.0) → (182.4,88.1) —"); + for (float t = 0f; t <= 1f; t += 0.01f) + Step(new Vector3(181.0f + t * 1.4f, 82.0f + t * 6.1f, 116.48f)); + _out.WriteLine($"after leg B: curr=0x{curr & 0xFFFFu:X4}"); + } + + [Fact] + public void A9B3Cottage_OutdoorSeed_AtThresholdGap_KeepsOutdoor() + { + // Guard against over-fixing: the documented threshold gap is contained + // by NO interior cell, so an outdoor-seeded pick there stays on the + // outdoor column (retail: nothing contains the centre → the landcell + // is the only container). The transparency at THIS exact spot is a + // RENDER question (outdoor root at a doorway), not membership. + 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); + RegisterBuildings(dats, cache, 0xA9B30000u); + + var gap = new Vector3(184.915f, 82.464f, 116.48f); + uint picked = CellTransit.FindCellList(cache, gap, FootRadius, 0xA9B3003Cu); + _out.WriteLine($"pick(seed 0x3C) at gap -> 0x{picked:X8}"); + Assert.Equal(0xA9B3003Cu, picked); + } + [Fact] public void ThresholdCottage_AdjacentClaim_LaterallyRecovers_ViaStabGraph() {