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;
///
/// #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.
///
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();
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();
CellTransit.FindTransitCellsSphere(
cache, cell102, 0xA9B30102u, new Vector3(185.70f, 85.5f, 117.0f),
FootRadius, nearCandidates, out bool nearStraddle);
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()
{
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);
}
}