acdream/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs
Erik be03146e30 #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>
2026-06-12 11:35:52 +02:00

304 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Conformance;
/// <summary>
/// #112 (2026-06-10): the A9B3 hill cottage has a real containment GAP inside
/// the house (world (184.9, 109.5, 116) / A9B3-local (184.9, 82.5) is in NO
/// interior cell while points ~0.7 m away are inside 0x100/0x103). The
/// 6dbbf95 escape hatch demoted the walker to the outdoor column there,
/// stranding them outdoor-classified deep indoors (no containment-based
/// re-promotion) → the outdoor flood rendered the interior transparent.
/// Retail find_cell_list KEEPS curr_cell when nothing contains the centre
/// (pc:308788-308825). These tests pin the replacement semantics against the
/// real dats.
/// </summary>
public sealed class Issue112MembershipTests
{
private readonly ITestOutputHelper _out;
public Issue112MembershipTests(ITestOutputHelper output) => _out = output;
private const float FootRadius = 0.48f;
private static PhysicsDataCache LoadLandblockInteriors(DatCollection dats, uint lbPrefix)
{
var cache = new PhysicsDataCache();
for (uint low = 0x0100; low <= 0x01FF; low++)
{
try { ConformanceDats.LoadEnvCell(dats, cache, lbPrefix | low); }
catch { }
}
return cache;
}
[Fact]
public void A9B3CottageGap_AtDoorway_StraddlesExitPlane_DemotesRetailFaithfully()
{
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);
// The gap point (A9B3-local frame, footcenter height): contained by NO
// interior cell (dat-scan fact from the live capture issue111-verify7).
var gap = new Vector3(184.915f, 82.464f, 116.48f);
uint picked = CellTransit.FindCellList(cache, gap, FootRadius, 0xA9B30104u);
_out.WriteLine($"pick(seed 0x104) at gap -> 0x{picked:X8}");
// 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 but no straddle → no outdoor admission flag
// (retail: var_44 stays 0, add_all_outside skipped). (BR-7 C4
// deleted the non-retail hasExitPortal topology output — the
// straddle flag is the only outdoor-admission signal, like
// retail.)
var farCandidates = new List<uint>();
CellTransit.FindTransitCellsSphere(
cache, cell102, 0xA9B30102u, new Vector3(183.0f, 86.5f, 117.0f),
FootRadius, farCandidates, out bool farStraddle);
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);
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()
{
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, 0xA9B40000u);
// The #111 gate shape: claim 0x172 (adjacent room), position deep
// inside 0x171 (the captured spawn). The sphere does not overlap
// 0x172's volume from there → the new lateral recovery searches the
// claim's stab list (retail find_visible_child_cell :311444) and
// self-heals to 0x171 — instead of the old outdoor demote.
var spawnFootCenter = new Vector3(155.390f, 11.020f, 94.0f + FootRadius);
uint picked = CellTransit.FindCellList(cache, spawnFootCenter, FootRadius, 0xA9B40172u);
_out.WriteLine($"pick(seed 0x172) at 0x171-interior -> 0x{picked:X8}");
Assert.Equal(0xA9B40171u, picked);
}
}