acdream/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs
Erik 02f4be72c0 fix(teleport D): cell-march preserves seed landblock id when no resident LB (no more lbX=0 outbound)
BuildCellSetAndPickContaining discarded the bool from TryGetTerrainOrigin — when
the current landblock's terrain hadn't been applied yet (priority-apply in flight
after a teleport or dungeon exit), blockOrigin was silently set to (0,0,0). The
AdjustToOutside/GetOutsideLcoord math treated world-frame sphere coordinates as
block-local and marched the cell one landblock per tick in the direction of movement
until lbX or lbY underflowed to 0x00. ACE rejected every subsequent move as a
failed transition.

Fix: honor the bool return. When terrain is unregistered for an OUTDOOR seed
(low < 0x0100), return currentCellId verbatim — "no block-local frame →
preserve". This mirrors the NO-LANDBLOCK verbatim contract in PhysicsEngine.Resolve
and is correct: the cell stays last-known-correct until terrain registers.
Indoor seeds are explicitly excluded (blockOrigin is never consumed by the indoor
pick path; outdoorPickAllowed=false for indoor seeds).

Reproduce + verify via CellMarchLandblockPreservationTests (two new FAILING-before
tests: WestEdge and SouthEdge with empty cache, no anchor → lbX/lbY preserved).
TeleportFarTownRunawayTests updated: no-anchor path now also preserves (pre-fix it
marched south to 0x59; post-fix returns currentCell unchanged).
CellTransitFindCellSetTests, Issue112MembershipTests, PhysicsEngineTests: added
RegisterTerrain for the streaming-center block (in production it is always resident
before outdoor resolves run; tests that used blockOrigin=(0,0,0) as an implicit
fallback now register the block explicitly). All 1567 tests pass.

Divergence AD-30 added to retail-divergence-register.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 13:38:48 +02:00

310 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 { }
}
// #145 D: register terrain for the block so TryGetTerrainOrigin succeeds for
// outdoor seeds (in production the streaming-center landblock is always resident
// before outdoor physics resolves run). Tests use block-local coordinates, so
// origin is (0,0,0). Without this, Component D's early-return for unregistered
// terrain would prevent the building-portal walk that promotes outdoor→indoor.
cache.CellGraph.RegisterTerrain(lbPrefix, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero);
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);
}
}