#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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-12 11:35:52 +02:00
parent 756ea61e30
commit be03146e30
2 changed files with 229 additions and 54 deletions

View file

@ -120,6 +120,168 @@ public sealed class Issue112MembershipTests
Assert.True(nearStraddle);
}
/// <summary>
/// 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.
/// </summary>
private static void RegisterBuildings(DatCollection dats, PhysicsDataCache cache, uint lbPrefix)
{
var lbInfo = dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>(lbPrefix | 0xFFFEu);
Assert.NotNull(lbInfo);
foreach (var building in lbInfo!.Buildings)
{
if (building.Portals.Count == 0) continue;
var portals = new List<BldPortalInfo>(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()
{