From be03146e3013508c77c017fb3c43581db35a7bc2 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 12 Jun 2026 11:35:52 +0200 Subject: [PATCH] #112 ROOT CAUSE: outdoor-seed pick lacked retail's growing-array walk - threshold tick-skip became absorbing The instrumented capture (cottage-112-capture1.log) + dat replay pinned the transparent-cottage mechanism end to end: 1. The A9B3 cottage's entry cell 0x104 is a 0.22 m-wide THRESHOLD band (x 184.68->184.46 at y~82). A running player (~13-16 cm/tick at 30 Hz) can cross it BETWEEN two physics ticks - the tick where the centre is inside 0x104 never happens. 2. Our outdoor-seed branch ran CheckBuildingTransit over a landcell snapshot and STOPPED - building-admitted entry cells were never expanded. The tick after the skip (centre in 0x100, a deep room not building-portal-adjacent) found no containing candidate -> the pick kept the outdoor landcell FOREVER (absorbing): the user walked the whole interior classified outdoor (render faithfully drew an outdoor frame = transparent walls), promoting only on touching portal-adjacent 0x102's own volume minutes later (captured: 0xA9B3003C -> 0xA9B30102 with no transitions in between). 3. Retail cannot strand: CObjCell::find_cell_list (0x0052b4e0) 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 foot sphere still overlaps the band one tick after the skip) and the walk expands 0x104's portals to 0x100 where containment wins. Recovery fires one tick after any skip. Fix: BuildCellSetAndPickContaining now runs retail's single growing walk for both seeds with per-cell-type dispatch (landcells -> CLandCell::find_transit_cells 0x00533800 -> CSortCell 0x00534060 -> check_building_transit 0x0052c5d0; envcells -> FindTransitCellsSphere with the straddle gate + once-per-walk outside add). The old indoor branch behavior is preserved (seed at index 0, hysteresis, straddle- gated outdoor pick); the outdoor branch gains the expansion + the indoor branch gains the retail landcell bridge dispatch for straddle-admitted landcells. Pins (dat-backed, Issue112MembershipTests): tick-skip recovery one tick past the threshold (RED pre-fix); run-speed entry replay across tick phases never strands outdoor; threshold-gap outdoor-seed keeps outdoor (over-fix guard); entry-walk replay diagnostic prints the full promotion chain (0x3C -> 0x104 -> 0x100 -> 0x103 -> 0x100 -> 0x102). Suites: App 246+1skip / Core 1438+2skip / UI 420 / Net 294. Co-Authored-By: Claude Fable 5 --- src/AcDream.Core/Physics/CellTransit.cs | 121 +++++++------ .../Conformance/Issue112MembershipTests.cs | 162 ++++++++++++++++++ 2 files changed, 229 insertions(+), 54 deletions(-) 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() {