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>
310 lines
16 KiB
C#
310 lines
16 KiB
C#
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);
|
||
}
|
||
}
|