diff --git a/docs/research/2026-06-03-p0-conformance-apparatus-notes.md b/docs/research/2026-06-03-p0-conformance-apparatus-notes.md new file mode 100644 index 0000000..83a8972 --- /dev/null +++ b/docs/research/2026-06-03-p0-conformance-apparatus-notes.md @@ -0,0 +1,70 @@ +# P0 — Conformance apparatus notes (characterized topology + goldens) + +2026-06-03. Companion to [`docs/superpowers/plans/2026-06-03-p0-conformance-apparatus.md`](../superpowers/plans/2026-06-03-p0-conformance-apparatus.md). +Source of truth for the cell ids + golden inputs the P0 conformance tests pin. All values below +were **characterized from the real client dats** (not guessed) by +`CottageDoorwayCharacterizationTests` against `%USERPROFILE%\Documents\Asheron's Call`. + +## Characterized Holtburg (landblock 0xA9B4) indoor neighborhood + +Scanned `0xA9B40140..0xA9B4017F`. All cells load with a real ContainmentBsp and `seenOutside=1`. +Cells share a per-BUILDING world origin (the building's reference frame). The distinct buildings: + +| Cell range | World origin | Stab | Identity | +|---|---|---|---| +| `0140..0150` (17 cells) | (130.50, 11.50, 94.00) | 17 | **Cottage-with-cellar** (#98 cellar saga; exit portals at 0145,014C,014E,014F,0150) | +| `0151..0169` (25 cells) | (107.50, 36.00, 94.00) | 24 | Larger building (inn) | +| `016A..016E` (5 cells) | (79.50, 37.50, 94.00) | 4 | Small building | +| **`016F..0175` (7 cells)** | **(161.93, 7.50, 94.00)** | 6 | **THE doorway-threshold building (master-plan 0170/0171)** | +| `0176..0178` | (65.21, 156.63, 66.00) | 2 | — | +| `0179..017A` | (158.18, 37.71, 94.00) | 1 | — | +| `017B..017F` | (161.72, 105.05, 66.00) | 4 | — | + +## The pinned threshold topology (master-plan `0031↔0170↔0171`) + +Building at world origin **(161.93, 7.50, 94.00)**: + +| Cell | Role | Portal dests | seenOutside | BSP | +|---|---|---|---|---| +| `0xA9B40170` | **vestibule / doorway** | `[0xFFFF (exit→outdoor), 0x0171]` | 1 | real | +| `0xA9B40171` | **room** (behind the door) | `[0x0170, 0x0173, 0x0175]` | 1 | real | + +Grid math confirms the outdoor side: origin (161.93, 7.50) → gridX = ⌊161.93/24⌋ = 6, +gridY = ⌊7.5/24⌋ = 0 → landcell id `6*8 + 0 + 1 = 49 = 0x31` → **outdoor landcell `0xA9B40031`**. +So the player crossing the doorway traverses `0031 (outdoor) → 0170 (vestibule, via the 0xFFFF exit +portal) → 0171 (room)` — exactly the master-plan ping-pong `0031↔0170↔0171`. **Verified real.** + +> Naming note: a 2026-05-21 capture dir called `0170/0171` the "inn 2nd floor"; that label was +> loose. By geometry it is the 7-cell building at (161.93, 7.50). Identity (cottage vs inn) is +> irrelevant to the conformance — what matters is `0170` carries the exit portal (the doorway) and +> `0171` is the room behind it. + +## Golden interior points (verified `point_in_cell == true`) + +Cell-LOCAL points whose `EnvCell.PointInCell(world)` returns true (the world form is +`Vector3.Transform(local, cellPhysics.WorldTransform)`): + +| Cell | Interior LOCAL point | Interior WORLD point | Notes | +|---|---|---|---| +| `0xA9B40170` | (5.865, -8.449, 0.417) | (156.06, 15.95, 94.42) | 115/125 grid-inset points inside (small/irregular vestibule) | +| `0xA9B40171` | (6.55, -3.25, 4.60) | (157.01, 13.69, 95.53)* | bounds-center = bsphere origin; 125/125 inside (clean box) | + +\* world recomputed by the test at runtime via the cell's WorldTransform; the value above is the +firstInside probe — the canonical bsphere-origin local (6.55,-3.25,4.60) is also fully interior. + +These are **retail-faithful by construction**: the ContainmentBsp is loaded from the same dats +retail loads, so a geometrically-correct containment answer is the retail answer. + +## Golden provenance summary + +| Golden | Provenance | Status | +|---|---|---| +| `point_in_cell` (interior true, far-away false) | geometric (real dat BSP) | autonomous (Task 3) | +| `find_cell_list` deep-inside picks `0171`/`0170` | geometric (real dat cells) | autonomous (Task 4) | +| `find_cell_list` doorway-threshold pick | **retail cdb trace** | Task 6 — USER GATE / mine existing traces | +| PVS visible-set | retail cdb `cell_draw_list` trace | deferred to P4 (Task 7 scaffold) | + +## P1-entry checklist (filled at end of P0) + +- [ ] Threshold golden GREEN (acdream already matches retail) or RED (P1 must fix) — recorded here. +- [ ] "P0 gate met: ≥1 retail-trace-backed assertion exists" — stated explicitly so P1 can begin. diff --git a/docs/superpowers/plans/2026-06-03-p0-conformance-apparatus.md b/docs/superpowers/plans/2026-06-03-p0-conformance-apparatus.md new file mode 100644 index 0000000..758c594 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-p0-conformance-apparatus.md @@ -0,0 +1,709 @@ +# P0 — Conformance Apparatus Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (inline) to +> implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the headless, dat-backed conformance apparatus that lets every later phase +(P1 membership, P2 door collision, P3 camera, P4 PView render) prove a port is **verbatim +retail** — not "vibes" — by asserting real-retail outcomes (`point_in_cell`, `find_cell_list`, +PVS visible-set) against fixtures loaded from the real client dats and against captured retail +cdb traces. + +**Architecture:** Three layers. (1) A dat-backed **fixture loader** that hydrates the Holtburg +cottage-doorway cell neighborhood from the real dats with their *real* containment BSPs (no +synthetic BSP, no JSON round-trip). (2) **Golden conformance tests** that pin retail-faithful +outcomes — `point_in_cell` is geometric ground truth (the BSP *is* retail data, loaded from the +same dats retail loads); `find_cell_list` pins the membership pick, with the subtle +doorway-threshold case backed by a captured **retail cdb trace**. (3) **cdb value-capture +tooling** — a new script that dumps `find_cell_list`'s cell-id argument/return at the threshold +(value, not just hit-count), plus a parser that turns its log into a golden fixture. The live +capture run is the single user-gated step; everything else is autonomous + headless. + +**Tech Stack:** C# / .NET 10, xUnit, `DatReaderWriter` (`DatCollection.Get`), the existing +`PhysicsDataCache` / `CellTransit` / `BSPQuery` engine, cdb (`acclient.pdb`) per the CLAUDE.md +retail-debugger toolchain. + +**Scope note — what P0 is NOT.** P0 does not change any production membership/collision/render +code. It only adds `tests/` + `tools/cdb/` + `docs/`. The PVS visible-set golden is +*scaffolded* (structure + retail anchor + a skipped placeholder) but not filled — a retail +`cell_draw_list` trace is a P3/P4-coupled capture and the PVS code itself is replaced in P4. +P0's load-bearing deliverable is the **membership** goldens (`point_in_cell` + `find_cell_list`) +that P1 consumes immediately, plus ≥1 assertion backed by a real retail trace (the P1 gate). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `tests/AcDream.Core.Tests/Conformance/ConformanceDats.cs` (create) | Shared dat-dir resolution + one-call headless load of an `EnvCell` (real `ContainmentBsp`) and a cached `CellPhysics` by cell id. The single seam tests use to reach the real dats. | +| `tests/AcDream.Core.Tests/Conformance/CottageDoorwayCharacterizationTests.cs` (create) | Characterize-and-pin the real cottage-doorway cell topology (ids, worldOrigin, portals incl. the `0xFFFF` exit portal, `seenOutside`, has-ContainmentBsp). Discovers the exact IDs the rest of P0 uses. | +| `tests/AcDream.Core.Tests/Conformance/PointInCellConformanceTests.cs` (create) | Golden `point_in_cell`: assert containment for geometrically-known points against the real BSP. Retail-faithful by construction. | +| `tests/AcDream.Core.Tests/Conformance/FindCellListConformanceTests.cs` (create) | Golden `find_cell_list`: unambiguous picks (clearly-inside / clearly-outside) from dats + the doorway-threshold pick pinned to a captured retail trace. | +| `tests/AcDream.Core.Tests/Conformance/RetailTrace.cs` (create) | Parser: read a `find-cell-list` retail cdb log → strongly-typed `RetailCellPick[]` golden records. TDD'd against a checked-in sample log line. | +| `tools/cdb/find-cell-list-capture.cdb` (create) | New cdb script: breakpoint `CObjCell::find_cell_list`, dump the input position + the returned containing cell id (value capture). Ready for the user to run against live retail at the cottage doorway. | +| `tools/cdb/README-find-cell-list-capture.md` (create) | Operator runbook for the capture: prerequisites, exact launch command, what to walk, where the log lands, how to fold it into the golden. | +| `docs/research/2026-06-03-p0-conformance-apparatus-notes.md` (create) | Living notes: the characterized cottage-doorway topology, the golden values + their provenance (geometric vs retail-trace), and the P1-entry checklist. | + +--- + +## Task 1 — Shared dat-backed fixture loader + +**Files:** +- Create: `tests/AcDream.Core.Tests/Conformance/ConformanceDats.cs` + +The loader mirrors the proven dat-read pattern in +`tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs:184-219` +(`DatCollection.Get` → `Get` → `CellStruct` → physics-verbatim +`worldTransform`) and the `EnvCell.FromDat` derivation +(`src/AcDream.Core/World/Cells/EnvCell.cs:42-76`). It returns BOTH a `EnvCell` (for +`PointInCell`) and a cached `CellPhysics` (for `CellTransit`), so membership tests have one seam. + +- [ ] **Step 1: Write the loader** + +```csharp +using System; +using System.IO; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.World.Cells; +using DatReaderWriter; +using DatReaderWriter.Options; +using DatEnvCell = DatReaderWriter.DBObjs.EnvCell; +using DatEnvironment = DatReaderWriter.DBObjs.Environment; +using Env = System.Environment; + +namespace AcDream.Core.Tests.Conformance; + +/// +/// P0 conformance apparatus — headless load of the real Holtburg dats. +/// Tests that need real cell geometry (the retail containment BSP) resolve +/// the dat dir here and load cells via . Returns +/// null dat dir when the dats are absent (CI) so callers can skip cleanly, +/// matching DoorBugTrajectoryReplayTests.ResolveDatDir. +/// +public static class ConformanceDats +{ + private const uint EnvironmentFilePrefix = 0x0D000000u; // dat namespace for Environment files + + /// The Holtburg landblock these fixtures live in. + public const uint HoltburgLandblock = 0xA9B40000u; + + /// Resolve the client dat directory, or null if unavailable (skip the test). + public static string? ResolveDatDir() + { + var fromEnv = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) + return fromEnv; + var def = Path.Combine( + Env.GetFolderPath(Env.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(def) ? def : null; + } + + /// The physics-verbatim cell→world transform (no +2cm render lift). + public static Matrix4x4 WorldTransform(DatEnvCell datCell) => + Matrix4x4.CreateFromQuaternion(datCell.Position.Orientation) * + Matrix4x4.CreateTranslation(datCell.Position.Origin); + + /// + /// Load one EnvCell from the dats with its REAL containment BSP, and register + /// it into as a CellPhysics. Returns the high-level + /// EnvCell (PointInCell) so a single load serves both membership predicates. + /// + public static EnvCell LoadEnvCell(DatCollection dats, PhysicsDataCache cache, uint cellId) + { + var datCell = dats.Get(cellId) + ?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found in dats"); + var environment = dats.Get(EnvironmentFilePrefix | datCell.EnvironmentId) + ?? throw new InvalidOperationException($"Environment 0x{datCell.EnvironmentId:X8} not found"); + if (!environment.Cells.TryGetValue(datCell.CellStructure, out var cellStruct) || cellStruct is null) + throw new InvalidOperationException($"CellStruct {datCell.CellStructure} missing from environment"); + + var world = WorldTransform(datCell); + cache.CacheCellStruct(cellId, datCell, cellStruct, world); // physics CellPhysics (real CellBSP) + return EnvCell.FromDat(cellId, datCell, cellStruct, world); // render/containment EnvCell (real ContainmentBsp) + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `dotnet build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug` +Expected: build succeeds (no test yet — Task 2 exercises it). + +- [ ] **Step 3: Commit** + +```bash +git add tests/AcDream.Core.Tests/Conformance/ConformanceDats.cs +git commit -m "test(p0): dat-backed cottage-doorway fixture loader (ConformanceDats)" +``` + +--- + +## Task 2 — Characterize and pin the cottage-doorway topology + +**Files:** +- Create: `tests/AcDream.Core.Tests/Conformance/CottageDoorwayCharacterizationTests.cs` +- Create: `docs/research/2026-06-03-p0-conformance-apparatus-notes.md` + +The master plan names the neighborhood loosely (`0xA9B4003x` + `0xA9B4017x`), and the `0170`/`0171` +ids are reused across buildings in landblock `0xA9B4`. So this task **discovers** the real +topology and pins it. The candidate indoor ids are `0xA9B40170` and `0xA9B40171` (the +ping-pong pair named in the master plan §0). The test prints each cell's structure on first run; +fill the asserts from the printout, then they become the pinned characterization. + +- [ ] **Step 1: Write the characterization test (observe form)** + +```csharp +using System; +using System.Linq; +using AcDream.Core.Physics; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Conformance; + +public class CottageDoorwayCharacterizationTests +{ + private readonly ITestOutputHelper _out; + public CottageDoorwayCharacterizationTests(ITestOutputHelper output) => _out = output; + + // Candidate indoor cells of the cottage-doorway ping-pong (master plan §0: 0031↔0170↔0171). + public static readonly uint[] CandidateIndoor = { 0xA9B40170u, 0xA9B40171u }; + + [Fact] + public void Characterize_CottageDoorwayCells_PrintStructure() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cache = new PhysicsDataCache(); + + foreach (var id in CandidateIndoor) + { + var cell = ConformanceDats.LoadEnvCell(dats, cache, id); + var phys = cache.GetCellStruct(id)!; + var origin = System.Numerics.Vector3.Transform( + System.Numerics.Vector3.Zero, phys.WorldTransform); + bool hasExitPortal = phys.Portals!.Any(p => p.OtherCellId == 0xFFFFu); + _out.WriteLine( + $"0x{id:X8}: worldOrigin=({origin.X:F2},{origin.Y:F2},{origin.Z:F2}) " + + $"seenOutside={cell.SeenOutside} hasContainmentBsp={cell.ContainmentBsp?.Root is not null} " + + $"portals={phys.Portals!.Count} exitPortal={hasExitPortal} " + + $"stab={cell.StabList.Count} " + + $"portalDests=[{string.Join(",", phys.Portals!.Select(p => $"0x{p.OtherCellId:X4}"))}]"); + } + } +} +``` + +- [ ] **Step 2: Run it and READ the printed structure** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~Characterize_CottageDoorwayCells" -l "console;verbosity=detailed"` +Expected: PASS, with two `0xA9B401XX: worldOrigin=... portalDests=[...]` lines in the output. +**Record those lines** in `docs/research/2026-06-03-p0-conformance-apparatus-notes.md` under a +"Characterized topology" heading. They are the source of truth for Tasks 2b/3. + +> If a candidate id throws "not found in dats", it is the wrong id — widen the candidate scan to +> `0xA9B40170..0xA9B40179` in the loop, re-run, and identify the two cells whose `worldOrigin` +> matches the cottage (low Y, near the `014x` cellar) and that form a portal pair (one's +> `portalDests` contains the other). Pin those two ids. + +- [ ] **Step 3: Pin the discovered values (assert form)** + +Replace the print-only body with explicit asserts using the recorded values, e.g. (substitute +the REAL numbers you recorded — these are illustrative): + +```csharp +[Fact] +public void CottageDoorway_Cell0170_IsVestibuleWithExitPortal() +{ + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) return; + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cache = new PhysicsDataCache(); + + var cell = ConformanceDats.LoadEnvCell(dats, cache, 0xA9B40170u); + var phys = cache.GetCellStruct(0xA9B40170u)!; + + Assert.True(cell.ContainmentBsp?.Root is not null, "real cell BSP must load"); + Assert.NotEmpty(phys.Portals!); + // Pin whatever the characterization printed — exit portal presence is the load-bearing fact: + Assert.Contains(phys.Portals!, p => p.OtherCellId == 0xFFFFu || p.OtherCellId == 0x0171u); +} +``` + +Keep the `Characterize_..._PrintStructure` test (it documents the data and re-runs cheaply). + +- [ ] **Step 4: Run to verify the pinned asserts pass** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~CottageDoorwayCharacterizationTests"` +Expected: PASS (both tests). + +- [ ] **Step 5: Commit** + +```bash +git add tests/AcDream.Core.Tests/Conformance/CottageDoorwayCharacterizationTests.cs docs/research/2026-06-03-p0-conformance-apparatus-notes.md +git commit -m "test(p0): characterize + pin cottage-doorway cell topology from dats" +``` + +--- + +## Task 3 — `point_in_cell` golden conformance + +**Files:** +- Create: `tests/AcDream.Core.Tests/Conformance/PointInCellConformanceTests.cs` +- Modify: `docs/research/2026-06-03-p0-conformance-apparatus-notes.md` + +Retail's `point_in_cell` for an EnvCell *is* `BSPTREE::point_in_cell_bsp` against `cell_bsp` +(master plan A6; reference doc §6.3). Our `EnvCell.PointInCell` → `BSPQuery.PointInsideCellBsp` +is the port. The BSP is loaded from the *same dats retail loads*, so a geometrically-correct +containment answer **is** the retail answer. Goldens: a point at the cell's world origin-ish +interior must be inside; a point far outside the landblock must be outside; the matching point +must be inside exactly one of the doorway pair. + +- [ ] **Step 1: Write the failing test** + +```csharp +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; + +namespace AcDream.Core.Tests.Conformance; + +public class PointInCellConformanceTests +{ + [Fact] + public void PointInCell_InteriorPoint_IsInsideItsOwnCell() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) return; + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cache = new PhysicsDataCache(); + + var cell = ConformanceDats.LoadEnvCell(dats, cache, 0xA9B40171u); + var phys = cache.GetCellStruct(0xA9B40171u)!; + + // A point just above the cell's covering-sphere centre, in world space, is interior. + var localCentre = phys.CellBSP is not null + ? cache.GetCellStruct(0xA9B40171u)!.WorldTransform // placeholder; replaced below + : default; + // Use the cell's bounding-sphere origin (cell-local) lifted to world: + var worldCentre = Vector3.Transform(CharacterizedInteriorLocal_0171, phys.WorldTransform); + + Assert.True(cell.PointInCell(worldCentre), + $"interior point {worldCentre} must be inside cell 0x0171's BSP"); + } + + [Fact] + public void PointInCell_FarAwayPoint_IsOutside() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) return; + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cache = new PhysicsDataCache(); + + var cell = ConformanceDats.LoadEnvCell(dats, cache, 0xA9B40171u); + var faraway = new Vector3(10000f, 10000f, 10000f); + Assert.False(cell.PointInCell(faraway), "a point 10 km away cannot be inside"); + } + + // Pin this from Task 2's characterization: a cell-LOCAL point clearly inside 0x0171's volume + // (e.g. its bounding-sphere origin, or (0,0,1) if the cell is centred on its local origin). + private static readonly Vector3 CharacterizedInteriorLocal_0171 = new(0f, 0f, 1.0f); +} +``` + +- [ ] **Step 2: Run to verify it fails (or reveals the right interior point)** + +Run: `dotnet test ... --filter "FullyQualifiedName~PointInCellConformanceTests" -l "console;verbosity=detailed"` +Expected: `PointInCell_FarAwayPoint_IsOutside` PASSES immediately. `..._IsInsideItsOwnCell` may +FAIL if `CharacterizedInteriorLocal_0171` isn't actually inside — that's the signal to use the +real interior point from Task 2 (the cell's bounding-sphere origin, printed by adding it to the +characterization output). + +- [ ] **Step 3: Fix the interior point from characterization data** + +Augment the Task-2 print to also emit the cell's bounding-sphere origin (cell-local), then set +`CharacterizedInteriorLocal_0171` to a point you've confirmed is inside (the sphere origin, or a +point nudged toward the floor). Remove the dead `localCentre` placeholder line. + +- [ ] **Step 4: Run to verify both pass** + +Run: `dotnet test ... --filter "FullyQualifiedName~PointInCellConformanceTests"` +Expected: PASS (both). + +- [ ] **Step 5: Commit** + +```bash +git add tests/AcDream.Core.Tests/Conformance/PointInCellConformanceTests.cs docs/research/2026-06-03-p0-conformance-apparatus-notes.md +git commit -m "test(p0): point_in_cell golden conformance vs real dat BSP" +``` + +--- + +## Task 4 — `find_cell_list` golden conformance (unambiguous cases) + +**Files:** +- Create: `tests/AcDream.Core.Tests/Conformance/FindCellListConformanceTests.cs` + +`CellTransit.FindCellList(cache, sphereCentre, radius, currentCellId)` is the membership pick. +Unambiguous goldens come straight from the real dats: a sphere clearly inside room `0x0171` +returns `0x0171`; a sphere clearly inside vestibule `0x0170` returns `0x0170`. These pin the pick +without needing a retail trace (the geometry is retail data). The *threshold* case (ping-pong) +is Task 6, backed by a retail trace. + +- [ ] **Step 1: Write the failing test** + +```csharp +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; + +namespace AcDream.Core.Tests.Conformance; + +public class FindCellListConformanceTests +{ + private const float FootRadius = 0.4f; // retail player foot-sphere radius (PhysicsBody default) + + [Fact] + public void FindCellList_DeepInsideRoom0171_Returns0171() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) return; + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cache = new PhysicsDataCache(); + + ConformanceDats.LoadEnvCell(dats, cache, 0xA9B40170u); + var room = ConformanceDats.LoadEnvCell(dats, cache, 0xA9B40171u); + var phys = cache.GetCellStruct(0xA9B40171u)!; + + // A world point confirmed interior to 0x0171 in Task 3: + var inside0171 = Vector3.Transform(DeepInsideLocal_0171, phys.WorldTransform); + Assert.True(room.PointInCell(inside0171), "fixture sanity: point must be inside 0x0171"); + + // find_cell_list seeded from the room itself must return the room. + uint picked = CellTransit.FindCellList(cache, inside0171, FootRadius, 0xA9B40171u); + Assert.Equal(0xA9B40171u, picked); + } + + // Pin from Task 3 (a cell-local point well inside 0x0171, away from any portal plane): + private static readonly Vector3 DeepInsideLocal_0171 = new(0f, 0f, 1.0f); +} +``` + +- [ ] **Step 2: Run to verify** + +Run: `dotnet test ... --filter "FullyQualifiedName~FindCellListConformanceTests" -l "console;verbosity=detailed"` +Expected: PASS. If it returns a different cell, record the actual return in the notes doc and +investigate whether the seed point is too near a portal plane (move it deeper). A genuine +divergence here is itself a finding — document it; do NOT change production code in P0. + +- [ ] **Step 3: Commit** + +```bash +git add tests/AcDream.Core.Tests/Conformance/FindCellListConformanceTests.cs +git commit -m "test(p0): find_cell_list golden — unambiguous interior picks from dats" +``` + +--- + +## Task 5 — Retail-trace parser + cdb value-capture tooling + +**Files:** +- Create: `tests/AcDream.Core.Tests/Conformance/RetailTrace.cs` +- Create: `tools/cdb/find-cell-list-capture.cdb` +- Create: `tools/cdb/README-find-cell-list-capture.md` + +The autonomous half of the retail-trace golden: the **parser** (TDD'd against a sample line) and +the **capture script** (ready to run). The capture *run* is the user gate in Task 6. + +The cdb script breakpoints `CObjCell::find_cell_list` (`acclient!CObjCell::find_cell_list`, +`0x52b4e0`). `this` (the seed cell, `thiscall` → `ecx`) carries `objcell_id` at a known offset; +the position is an argument. We log the seed cell id and the position, and (via a return +breakpoint) the picked cell id. The exact field offsets are confirmed at capture time with +`dt acclient!CObjCell @ecx` (CLAUDE.md retail-debugger watchouts). The script emits lines of the +form `[fcl] seed=0xHHHHHHHH px= py= pz= picked=0xHHHHHHHH`. + +- [ ] **Step 1: Write the failing parser test** + +```csharp +using System.Numerics; +using Xunit; + +namespace AcDream.Core.Tests.Conformance; + +public class RetailTraceTests +{ + [Fact] + public void Parse_FindCellListLine_YieldsSeedPosAndPicked() + { + const string line = + "[fcl] seed=0xA9B40170 px=141.5000 py=7.2200 pz=92.7400 picked=0xA9B40171"; + var rec = RetailTrace.ParseFindCellList(line); + Assert.NotNull(rec); + Assert.Equal(0xA9B40170u, rec!.SeedCellId); + Assert.Equal(new Vector3(141.5f, 7.22f, 92.74f), rec.Position); + Assert.Equal(0xA9B40171u, rec.PickedCellId); + } + + [Fact] + public void Parse_NonMatchingLine_ReturnsNull() + { + Assert.Null(RetailTrace.ParseFindCellList("[BP4] find_collisions hit#10170 collide=0")); + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test ... --filter "FullyQualifiedName~RetailTraceTests"` +Expected: FAIL — `RetailTrace` does not exist. + +- [ ] **Step 3: Write the parser** + +```csharp +using System; +using System.Globalization; +using System.Numerics; +using System.Text.RegularExpressions; + +namespace AcDream.Core.Tests.Conformance; + +/// A single retail find_cell_list pick captured via cdb (golden oracle). +public sealed record RetailCellPick(uint SeedCellId, Vector3 Position, uint PickedCellId); + +/// Parser for the find-cell-list-capture.cdb log format. +public static class RetailTrace +{ + private static readonly Regex Fcl = new( + @"^\[fcl\]\s+seed=0x(?[0-9A-Fa-f]{1,8})\s+" + + @"px=(?-?\d+(\.\d+)?)\s+py=(?-?\d+(\.\d+)?)\s+pz=(?-?\d+(\.\d+)?)\s+" + + @"picked=0x(?[0-9A-Fa-f]{1,8})\s*$", + RegexOptions.Compiled); + + public static RetailCellPick? ParseFindCellList(string line) + { + var m = Fcl.Match(line); + if (!m.Success) return null; + var ci = CultureInfo.InvariantCulture; + return new RetailCellPick( + SeedCellId: Convert.ToUInt32(m.Groups["seed"].Value, 16), + Position: new Vector3( + float.Parse(m.Groups["px"].Value, ci), + float.Parse(m.Groups["py"].Value, ci), + float.Parse(m.Groups["pz"].Value, ci)), + PickedCellId: Convert.ToUInt32(m.Groups["picked"].Value, 16)); + } +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `dotnet test ... --filter "FullyQualifiedName~RetailTraceTests"` +Expected: PASS (both). + +- [ ] **Step 5: Write the cdb capture script** + +``` +$$ find-cell-list-capture.cdb — value-capture of CObjCell::find_cell_list at the cottage doorway. +$$ Logs the seed cell id + input position + picked cell id (NOT just a hit count). +$$ Prereqs: retail in-world at the Holtburg cottage doorway; PDB matches (check_exe_pdb.py = MATCH). +$$ Offsets for objcell_id + the position arg are CONFIRMED at runtime with `dt acclient!CObjCell @ecx` +$$ and `dv` — edit the @ecx+OFFSET below if the dt dump shows a different layout. + +.logopen C:\Users\erikn\source\repos\acdream\find-cell-list-capture.log +.sympath C:\Users\erikn\source\repos\acdream\refs +.symopt+ 0x40 +.reload /f acclient.exe + +r $t0 = 0 +$$ Entry: dump seed cell id (this->objcell_id @ ecx+0x20 — VERIFY with dt) + the position arg. +bp acclient!CObjCell::find_cell_list "r $t0 = @$t0 + 1; .printf /D \"[fcl-entry#%d] seed=0x%08x\\n\", @$t0, poi(@ecx+0x20); .if (@$t0 >= 4000) { qd } .else { gc }" + +.printf \"find_cell_list capture armed. Walk SLOWLY in/out of the cottage doorway now.\\n\" +g +``` + +- [ ] **Step 6: Write the operator runbook** + +Create `tools/cdb/README-find-cell-list-capture.md` with: the `check_exe_pdb.py` MATCH +precondition; the exact PowerShell launch (`cdb.exe -pn acclient.exe -cf find-cell-list-capture.cdb`); +the instruction to first run `dt acclient!CObjCell @ecx` once to confirm the `objcell_id` offset +and `dv` to find the position arg, then edit the script; the walk to perform (stand in the +doorway, step in and out across the threshold 5–10×); where the log lands; and how to hand the +log to Task 6. + +- [ ] **Step 7: Commit** + +```bash +git add tests/AcDream.Core.Tests/Conformance/RetailTrace.cs tools/cdb/find-cell-list-capture.cdb tools/cdb/README-find-cell-list-capture.md +git commit -m "test(p0): retail find_cell_list trace parser + value-capture cdb script" +``` + +--- + +## Task 6 — The retail-trace golden (USER GATE) + P1-entry checklist + +**Files:** +- Modify: `tests/AcDream.Core.Tests/Conformance/FindCellListConformanceTests.cs` +- Create: `tests/AcDream.Core.Tests/Conformance/Fixtures/find-cell-list-threshold.log` (from the capture) +- Modify: `docs/research/2026-06-03-p0-conformance-apparatus-notes.md` + +This task has the one irreducible user-gated step: a live retail cdb capture. Everything is +prepped (Task 5). After the capture, encode ≥1 retail pick as a golden assertion — satisfying the +kickoff's "do NOT start P1 before ≥1 golden retail-trace assertion exists." + +- [ ] **Step 1: USER GATE — capture the retail trace** + +Mining the existing committed traces first (`docs/research/2026-05-21-a6-captures/scen1_inn_doorway/retail.decoded.log` +and `scen4_cottage_cellar/retail.decoded.log`): grep for any line carrying a `find_cell_list` +cell-id pick. If one exists in usable form, use it (no new capture needed). Otherwise, ask the +user to run `tools/cdb/find-cell-list-capture.cdb` per its README against live retail at the +cottage doorway, and place the resulting log at +`tests/AcDream.Core.Tests/Conformance/Fixtures/find-cell-list-threshold.log`. + +- [ ] **Step 2: Add the trace-backed golden** + +For each captured `RetailCellPick`, assert acdream's `CellTransit.FindCellList` returns the same +picked cell for the same `(SeedCellId, Position)`: + +```csharp +[Fact] +public void FindCellList_DoorwayThreshold_MatchesRetailTrace() +{ + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) return; + var fixturePath = System.IO.Path.Combine( + System.AppContext.BaseDirectory, "Conformance", "Fixtures", "find-cell-list-threshold.log"); + if (!System.IO.File.Exists(fixturePath)) return; // gate not yet satisfied — skip until captured + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cache = new PhysicsDataCache(); + foreach (var id in new[] { 0xA9B40170u, 0xA9B40171u }) + ConformanceDats.LoadEnvCell(dats, cache, id); + + foreach (var raw in System.IO.File.ReadAllLines(fixturePath)) + { + var pick = RetailTrace.ParseFindCellList(raw); + if (pick is null) continue; + uint ours = CellTransit.FindCellList(cache, pick.Position, 0.4f, pick.SeedCellId); + Assert.Equal(pick.PickedCellId, ours); + } +} +``` + +> Per master-plan no-shortcuts rule §4: if this assertion fails, it means *acdream diverges from +> retail* at the threshold — that is the P1 work, captured as a RED conformance test. Leave it RED +> (documents-the-bug) and let P1 turn it GREEN. Do NOT weaken the assertion. + +- [ ] **Step 3: Wire the fixture into the test project so it copies to output** + +Modify `tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` to copy `Conformance/Fixtures/**` +to the output dir (mirror how existing `Fixtures/issue98/**` is copied — find that `` +and add the `Conformance/Fixtures` glob, or confirm a wildcard already covers it). + +- [ ] **Step 4: Run the full conformance suite** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~Conformance"` +Expected: all GREEN, except possibly the documents-the-bug threshold test if acdream diverges +(that RED is a finding for P1, not a P0 failure). + +- [ ] **Step 5: Write the P1-entry checklist into the notes doc** + +In `docs/research/2026-06-03-p0-conformance-apparatus-notes.md`, record: the characterized +topology; every golden + its provenance (geometric vs retail-trace); whether the threshold golden +is GREEN (acdream already matches) or RED (P1 must fix); and the explicit statement "P0 gate met: +≥1 retail-trace-backed assertion exists" so P1 can begin. + +- [ ] **Step 6: Commit** + +```bash +git add tests/AcDream.Core.Tests/Conformance/ docs/research/2026-06-03-p0-conformance-apparatus-notes.md tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj +git commit -m "test(p0): retail-trace-backed find_cell_list threshold golden (P1 gate met)" +``` + +--- + +## Task 7 — PVS-golden scaffold (structure only, fill deferred to P3/P4) + +**Files:** +- Create: `tests/AcDream.Core.Tests/Conformance/PvsConformanceTests.cs` + +The master plan lists "PVS visible-set for a given (cell, eye) matches" under P0, but a retail +`cell_draw_list` trace is a P3/P4-coupled capture and the PVS code is replaced in P4. So P0 +scaffolds the test (intent + retail anchor + a skipped placeholder) so the structure exists when +P4 captures the retail visible-set. + +- [ ] **Step 1: Write the scaffold (skipped)** + +```csharp +using Xunit; + +namespace AcDream.Core.Tests.Conformance; + +/// +/// PVS (portal-visibility) conformance — scaffold. Retail oracle: +/// PView::ConstructView @ 0x005a57b0 (pc:433750) produces cell_draw_list; the golden is a +/// captured retail cell_draw_list for a given (viewer_cell, eye). Filled in P3/P4 when the +/// camera viewer-cell + ConstructView ports land and a retail cell_draw_list trace is captured +/// (new cdb script breakpointing PView::DrawCells / cell_draw_list, sibling to +/// find-cell-list-capture.cdb). See docs/research/2026-06-02-retail-render-pipeline-full-reference.md §3. +/// +public class PvsConformanceTests +{ + [Fact(Skip = "P0 scaffold — filled in P4 with a captured retail cell_draw_list trace")] + public void Pvs_CottageInterior_MatchesRetailCellDrawList() { } +} +``` + +- [ ] **Step 2: Verify it builds + reports as skipped** + +Run: `dotnet test ... --filter "FullyQualifiedName~PvsConformanceTests"` +Expected: 1 skipped, 0 failed. + +- [ ] **Step 3: Commit** + +```bash +git add tests/AcDream.Core.Tests/Conformance/PvsConformanceTests.cs +git commit -m "test(p0): PVS-golden conformance scaffold (filled in P4)" +``` + +--- + +## Self-Review + +**Spec coverage (master-plan P0 + reference §G4):** +- "Headless fixtures of the cottage neighborhood loaded from real dats" → Task 1 (`ConformanceDats.LoadEnvCell`) + Task 2 (characterize). +- "`point_in_cell` matches" → Task 3 (geometric goldens vs real BSP). +- "`find_cell_list` returns the same cell as a captured retail trace at the threshold" → Task 4 (unambiguous) + Task 6 (threshold, retail-trace-backed). +- "the PVS visible-set ... matches" → Task 7 (scaffold; fill in P4, documented). +- "Use the existing `ACDREAM_CAPTURE_RESOLVE` + cdb retail traces" → Task 5 (parser + capture script) + Task 6 (mine existing traces first, else live capture). +- "≥1 golden retail-trace assertion before P1" → Task 6 (the P1 gate). + +**Placeholder scan:** Interior-point constants (`*_Local_0171`) and pinned cell asserts are +explicitly characterization-derived (Task 2/3 print-then-pin) — concrete process, not hand-waving. +The cdb field offset (`ecx+0x20`) is flagged as runtime-verified (`dt acclient!CObjCell @ecx`). + +**Type consistency:** `ConformanceDats.LoadEnvCell` returns `EnvCell`; `cache.GetCellStruct(id)` +returns `CellPhysics` (matches `DoorBugTrajectoryReplayTests`); `CellTransit.FindCellList(cache, +Vector3, float, uint) → uint` (matches the membership map); `RetailTrace.ParseFindCellList(string) +→ RetailCellPick?`. Consistent across Tasks 1/3/4/6. + +**Risks / open verifications (resolve during execution, not blocking):** +- `PhysicsDataCache.CacheCellStruct` + `GetCellStruct` + `CellPhysics.WorldTransform/Portals/CellBSP` + member names are from the door-test read at `DoorBugTrajectoryReplayTests.cs:213-225`; verify + exact casing on first compile. +- The two cottage-doorway ids may not be `0170`/`0171` — Task 2 discovers the truth and the + later tasks consume whatever Task 2 pins. +- Whether the existing committed retail traces already contain a usable `find_cell_list` pick is + checked first in Task 6 Step 1 before asking the user for a live capture. diff --git a/tests/AcDream.Core.Tests/Conformance/ConformanceDats.cs b/tests/AcDream.Core.Tests/Conformance/ConformanceDats.cs new file mode 100644 index 0000000..1c056c1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Conformance/ConformanceDats.cs @@ -0,0 +1,66 @@ +using System; +using System.IO; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.World.Cells; +using DatReaderWriter; +using DatReaderWriter.Options; +using DatEnvCell = DatReaderWriter.DBObjs.EnvCell; +using DatEnvironment = DatReaderWriter.DBObjs.Environment; +using Env = System.Environment; + +namespace AcDream.Core.Tests.Conformance; + +/// +/// P0 conformance apparatus — headless load of the real Holtburg dats. +/// Tests that need real cell geometry (the retail containment BSP) resolve +/// the dat dir here and load cells via . Returns +/// null dat dir when the dats are absent (CI) so callers can skip cleanly, +/// matching DoorBugTrajectoryReplayTests.ResolveDatDir. +/// +/// Mirrors the proven dat-read pattern at +/// DoorBugTrajectoryReplayTests.cs:184-219 + EnvCell.FromDat (EnvCell.cs:42-76). +/// +public static class ConformanceDats +{ + private const uint EnvironmentFilePrefix = 0x0D000000u; // dat namespace for Environment files + + /// The Holtburg landblock these fixtures live in. + public const uint HoltburgLandblock = 0xA9B40000u; + + /// Resolve the client dat directory, or null if unavailable (skip the test). + public static string? ResolveDatDir() + { + var fromEnv = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) + return fromEnv; + var def = Path.Combine( + Env.GetFolderPath(Env.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(def) ? def : null; + } + + /// The physics-verbatim cell→world transform (no +2cm render lift). + public static Matrix4x4 WorldTransform(DatEnvCell datCell) => + Matrix4x4.CreateFromQuaternion(datCell.Position.Orientation) * + Matrix4x4.CreateTranslation(datCell.Position.Origin); + + /// + /// Load one EnvCell from the dats with its REAL containment BSP, and register + /// it into as a CellPhysics. Returns the high-level + /// EnvCell (PointInCell) so a single load serves both membership predicates. + /// + public static EnvCell LoadEnvCell(DatCollection dats, PhysicsDataCache cache, uint cellId) + { + var datCell = dats.Get(cellId) + ?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found in dats"); + var environment = dats.Get(EnvironmentFilePrefix | datCell.EnvironmentId) + ?? throw new InvalidOperationException($"Environment 0x{datCell.EnvironmentId:X8} not found"); + if (!environment.Cells.TryGetValue(datCell.CellStructure, out var cellStruct) || cellStruct is null) + throw new InvalidOperationException($"CellStruct {datCell.CellStructure} missing from environment"); + + var world = WorldTransform(datCell); + cache.CacheCellStruct(cellId, datCell, cellStruct, world); // physics CellPhysics (real CellBSP) + return EnvCell.FromDat(cellId, datCell, cellStruct, world); // render/containment EnvCell (real ContainmentBsp) + } +} diff --git a/tests/AcDream.Core.Tests/Conformance/CottageDoorwayCharacterizationTests.cs b/tests/AcDream.Core.Tests/Conformance/CottageDoorwayCharacterizationTests.cs new file mode 100644 index 0000000..f45d765 --- /dev/null +++ b/tests/AcDream.Core.Tests/Conformance/CottageDoorwayCharacterizationTests.cs @@ -0,0 +1,155 @@ +using System; +using System.Linq; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Conformance; + +/// +/// P0 Task 2 — characterize the real Holtburg cottage neighborhood from the +/// dats so the rest of P0 pins golden outcomes against verified cell ids, +/// not the master plan's loose "0031↔0170↔0171". The cottage cellar is the +/// 0x014x range (#98 fixtures); the doorway room/vestibule is in 0x017x. +/// +public class CottageDoorwayCharacterizationTests +{ + private readonly ITestOutputHelper _out; + public CottageDoorwayCharacterizationTests(ITestOutputHelper output) => _out = output; + + [Fact] + public void Characterize_CottageNeighborhood_PrintStructure() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + + // Scan the cellar (014x) + intermediate (015x/016x) + doorway (017x) + // indoor cell range in landblock 0xA9B4. Print every cell that loads. + for (uint low = 0x0140; low <= 0x017F; low++) + { + uint id = ConformanceDats.HoltburgLandblock | low; + var cache = new PhysicsDataCache(); + try + { + var cell = ConformanceDats.LoadEnvCell(dats, cache, id); + var phys = cache.GetCellStruct(id)!; + var origin = Vector3.Transform(Vector3.Zero, phys.WorldTransform); + bool exit = phys.Portals.Any(p => p.OtherCellId == 0xFFFFu); + _out.WriteLine( + $"0x{id:X8}: origin=({origin.X,7:F2},{origin.Y,7:F2},{origin.Z,6:F2}) " + + $"seenOut={(cell.SeenOutside ? 1 : 0)} bsp={(cell.ContainmentBsp?.Root is not null ? 1 : 0)} " + + $"portals={phys.Portals.Count} exit={(exit ? 1 : 0)} stab={cell.StabList.Count} " + + $"dests=[{string.Join(",", phys.Portals.Select(p => $"0x{p.OtherCellId:X4}"))}]"); + } + catch (Exception ex) + { + // Most ids in the range won't exist — that's expected; skip silently + // unless it's an unexpected failure shape. + if (ex is not InvalidOperationException) + _out.WriteLine($"0x{id:X8}: ERROR {ex.GetType().Name}: {ex.Message}"); + } + } + } + + // The verified threshold building (origin 161.93,7.50,94.00): + // 0xA9B40170 vestibule (exit portal 0xFFFF + portal to 0171) + // 0xA9B40171 room (portals to 0170/0173/0175) + // Outdoor landcell at that XY = 0xA9B40031 (grid 6,0). + public const uint Vestibule0170 = 0xA9B40170u; + public const uint Room0171 = 0xA9B40171u; + public const uint OutdoorLandcell0031 = 0xA9B40031u; + + [Fact] + public void Characterize_Doorway_FindInteriorPoints() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + foreach (var id in new[] { Vestibule0170, Room0171 }) + { + var cache = new PhysicsDataCache(); + var cell = ConformanceDats.LoadEnvCell(dats, cache, id); + var phys = cache.GetCellStruct(id)!; + _out.WriteLine($"0x{id:X8}: localBounds min=({cell.LocalBoundsMin.X:F2},{cell.LocalBoundsMin.Y:F2},{cell.LocalBoundsMin.Z:F2}) " + + $"max=({cell.LocalBoundsMax.X:F2},{cell.LocalBoundsMax.Y:F2},{cell.LocalBoundsMax.Z:F2})"); + + // Probe a 5×5×5 grid inset 15% from the bounds for the first cell-LOCAL + // point whose PointInCell (world) is true. That point is the golden interior. + var min = cell.LocalBoundsMin; var max = cell.LocalBoundsMax; + int inside = 0; Vector3? firstInsideLocal = null; + for (int ix = 1; ix <= 5; ix++) + for (int iy = 1; iy <= 5; iy++) + for (int iz = 1; iz <= 5; iz++) + { + var local = new Vector3( + min.X + (max.X - min.X) * ix / 6f, + min.Y + (max.Y - min.Y) * iy / 6f, + min.Z + (max.Z - min.Z) * iz / 6f); + var world = Vector3.Transform(local, phys.WorldTransform); + if (cell.PointInCell(world)) { inside++; firstInsideLocal ??= local; } + } + _out.WriteLine($" insidePoints={inside}/125 firstInsideLocal=" + + (firstInsideLocal is { } p + ? $"({p.X:F3},{p.Y:F3},{p.Z:F3}) world={Vector3.Transform(p, phys.WorldTransform):F3}" + : "NONE")); + } + } + + // ── Pinned regression guards (the characterized facts) ─────────────── + + // Verified interior points (cell-LOCAL), characterized above: + public static readonly Vector3 Interior0170Local = new(5.865f, -8.449f, 0.417f); + public static readonly Vector3 Interior0171Local = new(6.55f, -3.25f, 4.60f); // bsphere origin + + [Fact] + public void Doorway_Topology_IsPinned() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) return; + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cache = new PhysicsDataCache(); + + var vestibule = ConformanceDats.LoadEnvCell(dats, cache, Vestibule0170); + var room = ConformanceDats.LoadEnvCell(dats, cache, Room0171); + var vPhys = cache.GetCellStruct(Vestibule0170)!; + var rPhys = cache.GetCellStruct(Room0171)!; + + Assert.True(vestibule.ContainmentBsp?.Root is not null, "vestibule must have a real BSP"); + Assert.True(room.ContainmentBsp?.Root is not null, "room must have a real BSP"); + Assert.True(vestibule.SeenOutside); + Assert.True(room.SeenOutside); + + // Vestibule 0170: exit portal (0xFFFF) + portal to room 0171. + Assert.Contains(vPhys.Portals, p => p.OtherCellId == 0xFFFFu); + Assert.Contains(vPhys.Portals, p => p.OtherCellId == 0x0171u); + // Room 0171: portals to vestibule + the two side rooms; NO exit portal. + Assert.Contains(rPhys.Portals, p => p.OtherCellId == 0x0170u); + Assert.DoesNotContain(rPhys.Portals, p => p.OtherCellId == 0xFFFFu); + } + + [Fact] + public void Doorway_InteriorPoints_ArePinned() + { + var datDir = ConformanceDats.ResolveDatDir(); + if (datDir is null) return; + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cache = new PhysicsDataCache(); + + var vestibule = ConformanceDats.LoadEnvCell(dats, cache, Vestibule0170); + var room = ConformanceDats.LoadEnvCell(dats, cache, Room0171); + var vWorld = Vector3.Transform(Interior0170Local, cache.GetCellStruct(Vestibule0170)!.WorldTransform); + var rWorld = Vector3.Transform(Interior0171Local, cache.GetCellStruct(Room0171)!.WorldTransform); + + Assert.True(vestibule.PointInCell(vWorld), $"pinned vestibule interior {vWorld} must be inside 0170"); + Assert.True(room.PointInCell(rWorld), $"pinned room interior {rWorld} must be inside 0171"); + // The room interior is NOT inside the vestibule (distinct cells). + Assert.False(vestibule.PointInCell(rWorld), "room interior must not be inside the vestibule"); + Assert.False(room.PointInCell(new Vector3(10000f, 10000f, 10000f)), "10km-away point cannot be inside"); + } +}