From bd0244f2034f3eb8e9f6da265ae3d2f4e82586e8 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 2 Jun 2026 08:46:27 +0200 Subject: [PATCH] docs(plan): UCG Stage 1 (ObjCell scaffold) implementation plan 8 TDD tasks (RED->GREEN), Core-only, zero behavior change, built alongside the legacy cell systems. Grounded in the retail CObjCell survey + acdream inventory + #98 fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-02-unified-cell-graph-stage1.md | 997 ++++++++++++++++++ 1 file changed, 997 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-unified-cell-graph-stage1.md diff --git a/docs/superpowers/plans/2026-06-02-unified-cell-graph-stage1.md b/docs/superpowers/plans/2026-06-02-unified-cell-graph-stage1.md new file mode 100644 index 0000000..2496b83 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-unified-cell-graph-stage1.md @@ -0,0 +1,997 @@ +# Unified Cell Graph — Stage 1 (ObjCell Scaffold) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land a retail-faithful `ObjCell` cell-graph (base + `EnvCell` + `LandCell` + `CellPortal` + `CellGraph`) in `AcDream.Core`, populated from existing dat/physics data, consumed by nobody — zero behavior change. + +**Architecture:** New types in `AcDream.Core.World.Cells` own the unified CPU cell model (id + magnitude dispatch, transform/bounds, portals, stab-list, `SeenOutside`, containment-BSP ref). Built **alongside** the existing render `CellVisibility`/`LoadedCell` and physics `PhysicsDataCache`/`CellPhysics` (which stay untouched and authoritative this stage). `LandCell`s are synthesized on lookup from `TerrainSurface`. Spec: `docs/superpowers/specs/2026-06-02-unified-cell-graph-stage1-design.md`. Evidence: `docs/research/2026-06-02-render-cell-membership-evidence.md`. + +**Tech Stack:** C# / .NET 10, xUnit (`[Fact]`/`[Theory]`), `System.Numerics`, `DatReaderWriter` dat types. + +**Conventions:** +- Branch `claude/thirsty-goldberg-51bb9b` (unpushed — do NOT push). +- Every commit message ends with the trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) ` (shown only in Task 1 to save space; apply to every commit). +- `dotnet build` + `dotnet test` green before each commit. Run build from repo root: `dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug` and `dotnet build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug`. +- Run a single test: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~."`. +- New types are `public` (Stage 3 App-layer reads them; `AcDream.App` cannot see Core internals). + +--- + +## File Structure + +**Create (production):** +- `src/AcDream.Core/World/Cells/CellPortal.cs` — unified portal struct (superset of the 3 legacy portal types). +- `src/AcDream.Core/World/Cells/ObjCell.cs` — abstract base. +- `src/AcDream.Core/World/Cells/EnvCell.cs` — indoor cell + `FromDat` factory. +- `src/AcDream.Core/World/Cells/LandCell.cs` — outdoor cell, synthesized from `TerrainSurface`. +- `src/AcDream.Core/World/Cells/CellGraph.cs` — container + `GetVisible` resolver + population API + inert `CurrCell`. + +**Modify (population hooks — inert):** +- `src/AcDream.Core/Physics/PhysicsDataCache.cs` — add `CellGraph` property; add `EnvCell` to the graph at the top of `CacheCellStruct` (before the null-BSP drop). +- `src/AcDream.Core/Physics/PhysicsEngine.cs` — `RegisterTerrain` on `AddLandblock`; `RemoveLandblock` on the graph. + +**Create (tests, `tests/AcDream.Core.Tests/World/Cells/`):** +- `ObjCellBaseTests.cs`, `EnvCellTests.cs`, `EnvCellFromDatTests.cs`, `LandCellTests.cs`, `CellGraphTests.cs`, `CellGraphFixtureTests.cs`. +- `tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs` — the wiring integration test. + +No `.slnx` change needed (new files compile into existing projects). + +--- + +## Task 1: `CellPortal` struct + `ObjCell` base + +**Files:** +- Create: `src/AcDream.Core/World/Cells/CellPortal.cs`, `src/AcDream.Core/World/Cells/ObjCell.cs` +- Test: `tests/AcDream.Core.Tests/World/Cells/ObjCellBaseTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +// tests/AcDream.Core.Tests/World/Cells/ObjCellBaseTests.cs +using System.Numerics; +using AcDream.Core.World.Cells; +using Xunit; + +namespace AcDream.Core.Tests.World.Cells; + +public class ObjCellBaseTests +{ + // Minimal concrete subclass so the abstract base can be exercised. + private sealed class StubCell : ObjCell + { + public StubCell(uint id) + : base(id, Matrix4x4.Identity, Matrix4x4.Identity, + Vector3.Zero, Vector3.One, + System.Array.Empty(), System.Array.Empty(), false) { } + public override bool PointInCell(Vector3 worldPoint) => false; + } + + [Theory] + [InlineData(0xA9B40174u, true)] // low 0x0174 >= 0x100 -> env + [InlineData(0xA9B40005u, false)] // low 0x0005 < 0x100 -> land + [InlineData(0xA9B40100u, true)] // boundary: 0x100 is env + [InlineData(0xA9B400FFu, false)] // boundary: 0x0FF is land + public void IsEnv_DispatchesByLow16Magnitude(uint id, bool expected) + => Assert.Equal(expected, new StubCell(id).IsEnv); + + [Fact] + public void Ctor_StoresBaseProperties() + { + var c = new StubCell(0xA9B40174u); + Assert.Equal(0xA9B40174u, c.Id); + Assert.Equal(Vector3.One, c.LocalBoundsMax); + Assert.Empty(c.Portals); + Assert.Empty(c.StabList); + Assert.False(c.SeenOutside); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~ObjCellBaseTests"` +Expected: FAIL — compile error, `ObjCell`/`CellPortal` do not exist. + +- [ ] **Step 3: Write `CellPortal`** + +```csharp +// src/AcDream.Core/World/Cells/CellPortal.cs +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.Core.World.Cells; + +/// +/// Unified cell-to-cell portal edge. Superset of the three legacy portal types +/// (render CellPortalInfo, physics PortalInfo, PortalPlane). +/// Retail anchor: CCellPortal (acclient.h:32300). +/// +public readonly struct CellPortal +{ + public uint OtherCellId { get; } + public ushort OtherPortalId { get; } // reciprocal back-link (dropped by physics PortalInfo) + public ushort PolygonId { get; } + public ushort Flags { get; } + /// Matches the physics PortalInfo.PortalSide convention (PortalInfo.cs:44). + public bool PortalSide => (Flags & 0x2) == 0; + /// Cell-local portal polygon vertices. Carried now; consumed by PView at Stage 3. + public IReadOnlyList PolygonLocal { get; } + + public CellPortal(uint otherCellId, ushort otherPortalId, ushort polygonId, ushort flags, + IReadOnlyList? polygonLocal = null) + { + OtherCellId = otherCellId; + OtherPortalId = otherPortalId; + PolygonId = polygonId; + Flags = flags; + PolygonLocal = polygonLocal ?? Array.Empty(); + } +} +``` + +- [ ] **Step 4: Write `ObjCell` base** + +```csharp +// src/AcDream.Core/World/Cells/ObjCell.cs +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.Core.World.Cells; + +/// +/// Base for every cell the player can stand in. Retail anchor: CObjCell +/// (acclient.h:30915). The id magnitude is the type discriminator +/// (): low-16 >= 0x100 => indoor , +/// else outdoor . +/// +public abstract class ObjCell +{ + public uint Id { get; } + public Matrix4x4 WorldTransform { get; } + public Matrix4x4 InverseWorldTransform { get; } + public Vector3 LocalBoundsMin { get; } + public Vector3 LocalBoundsMax { get; } + public IReadOnlyList Portals { get; } + public IReadOnlyList StabList { get; } + public bool SeenOutside { get; } + + /// Retail magnitude dispatch (CObjCell::GetVisible, pseudo_c:308215). + public bool IsEnv => (Id & 0xFFFFu) >= 0x100u; + + protected ObjCell(uint id, Matrix4x4 worldTransform, Matrix4x4 inverseWorldTransform, + Vector3 localBoundsMin, Vector3 localBoundsMax, + IReadOnlyList portals, IReadOnlyList stabList, + bool seenOutside) + { + Id = id; + WorldTransform = worldTransform; + InverseWorldTransform = inverseWorldTransform; + LocalBoundsMin = localBoundsMin; + LocalBoundsMax = localBoundsMax; + Portals = portals; + StabList = stabList; + SeenOutside = seenOutside; + } + + /// Retail CObjCell::point_in_cell (vtable +0x84). Is a world point inside this cell? + public abstract bool PointInCell(Vector3 worldPoint); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~ObjCellBaseTests"` +Expected: PASS (6 cases). + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.Core/World/Cells/CellPortal.cs src/AcDream.Core/World/Cells/ObjCell.cs tests/AcDream.Core.Tests/World/Cells/ObjCellBaseTests.cs +git commit -m "feat(core): UCG Stage 1 — ObjCell base + CellPortal + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: `EnvCell` (ctor + `PointInCell` AABB/BSP) + +**Files:** +- Create: `src/AcDream.Core/World/Cells/EnvCell.cs` +- Test: `tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +// tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs +using System.Numerics; +using AcDream.Core.World.Cells; +using Xunit; + +namespace AcDream.Core.Tests.World.Cells; + +public class EnvCellTests +{ + private static EnvCell Make(Vector3 min, Vector3 max, Matrix4x4? transform = null) + { + var t = transform ?? Matrix4x4.Identity; + Matrix4x4.Invert(t, out var inv); + return new EnvCell(0xA9B40174u, t, inv, min, max, + System.Array.Empty(), System.Array.Empty(), + seenOutside: false, containmentBsp: null); + } + + [Fact] + public void PointInCell_NullBsp_Aabb_InsideIsTrue() + => Assert.True(Make(new Vector3(0,0,0), new Vector3(10,10,10)).PointInCell(new Vector3(5,5,5))); + + [Fact] + public void PointInCell_NullBsp_Aabb_OutsideIsFalse() + => Assert.False(Make(new Vector3(0,0,0), new Vector3(10,10,10)).PointInCell(new Vector3(20,5,5))); + + [Fact] + public void PointInCell_TransformsWorldToLocalBeforeTesting() + { + // Cell translated +100 in X. World (105,5,5) -> local (5,5,5), inside [0,10]^3. + var c = Make(new Vector3(0,0,0), new Vector3(10,10,10), Matrix4x4.CreateTranslation(100,0,0)); + Assert.True(c.PointInCell(new Vector3(105,5,5))); + Assert.False(c.PointInCell(new Vector3(5,5,5))); // local (-95,5,5), outside + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~EnvCellTests"` +Expected: FAIL — `EnvCell` does not exist. + +- [ ] **Step 3: Write `EnvCell` (ctor + `PointInCell` only; `FromDat` is Task 3)** + +```csharp +// src/AcDream.Core/World/Cells/EnvCell.cs +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; // BSPQuery +using DatReaderWriter.Types; // CellBSPTree + +namespace AcDream.Core.World.Cells; + +/// Indoor room cell. Retail anchor: CEnvCell (acclient.h:32072). +public sealed class EnvCell : ObjCell +{ + /// Cell-containment BSP (retail CellStruct.CellBSP). Null => AABB fallback. + public CellBSPTree? ContainmentBsp { get; } + + public EnvCell(uint id, Matrix4x4 worldTransform, Matrix4x4 inverseWorldTransform, + Vector3 localBoundsMin, Vector3 localBoundsMax, + IReadOnlyList portals, IReadOnlyList stabList, + bool seenOutside, CellBSPTree? containmentBsp) + : base(id, worldTransform, inverseWorldTransform, localBoundsMin, localBoundsMax, + portals, stabList, seenOutside) + { + ContainmentBsp = containmentBsp; + } + + public override bool PointInCell(Vector3 worldPoint) + { + var local = Vector3.Transform(worldPoint, InverseWorldTransform); + if (ContainmentBsp?.Root is not null) + return BSPQuery.PointInsideCellBsp(ContainmentBsp.Root, local); // BSPQuery.cs:1034 + // AABB fallback for BSP-less cells (spec §5.1 null-BSP inclusion). + return local.X >= LocalBoundsMin.X && local.X <= LocalBoundsMax.X + && local.Y >= LocalBoundsMin.Y && local.Y <= LocalBoundsMax.Y + && local.Z >= LocalBoundsMin.Z && local.Z <= LocalBoundsMax.Z; + } +} +``` + +> Note: the BSP branch is a one-line delegate to the already-tested `BSPQuery.PointInsideCellBsp`; it is validated end-to-end when real dat cells (non-null `CellBSP`) flow at Stage 3. The #98 fixtures hydrate `CellBSP=null`, so fixture/AABB is the path under test here. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~EnvCellTests"` +Expected: PASS (3 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core/World/Cells/EnvCell.cs tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs +git commit -m "feat(core): UCG Stage 1 — EnvCell + PointInCell (AABB/BSP)" +``` + +--- + +## Task 3: `EnvCell.FromDat` derivation + +**Files:** +- Modify: `src/AcDream.Core/World/Cells/EnvCell.cs` (add the static factory) +- Test: `tests/AcDream.Core.Tests/World/Cells/EnvCellFromDatTests.cs` + +> Mirrors the proven render derivation in `BuildLoadedCell` (`GameWindow.cs:5588-5704`) so the two stay byte-equivalent (spec risk #2). The #98 fixtures are `CellPhysics` dumps (no dat `EnvCell`/`OtherPortalId`/`SeenOutside`), so this is tested with **synthetic dat objects** using empty vertex/polygon collections (bounds-from-real-verts is covered by the fixture test, Task 6). + +- [ ] **Step 1: Confirm dat-type constructors, then write the failing test** + +First read these generated types to confirm parameterless ctors + public settable members (adjust the initializers below only if a field name differs): +`references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/EnvCell.generated.cs`, +`.../Generated/Types/CellStruct.generated.cs`, `.../Types/VertexArray.generated.cs`, +`.../Types/CellPortal.generated.cs`, `.../Enums/EnvCellFlags.generated.cs`, +`.../Enums/PortalFlags.generated.cs`. + +```csharp +// tests/AcDream.Core.Tests/World/Cells/EnvCellFromDatTests.cs +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.World.Cells; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; +using DatEnvCell = DatReaderWriter.DBObjs.EnvCell; +using DatCellPortal = DatReaderWriter.Types.CellPortal; + +namespace AcDream.Core.Tests.World.Cells; + +public class EnvCellFromDatTests +{ + [Fact] + public void FromDat_DerivesSeenOutside_OtherPortalId_PrefixedStab_AndBoundsFallback() + { + var cellStruct = new CellStruct + { + VertexArray = new VertexArray { Vertices = new Dictionary() }, // empty -> bounds fallback + Polygons = new Dictionary(), + CellBSP = null, + }; + var dat = new DatEnvCell + { + Flags = EnvCellFlags.SeenOutside, + CellPortals = new List + { + new() { OtherCellId = 0x0105, PolygonId = 0, OtherPortalId = 7, Flags = (PortalFlags)0 }, + }, + VisibleCells = new List { 0x0105, 0x0106 }, + }; + + var env = EnvCell.FromDat(0xA9B40104u, dat, cellStruct, Matrix4x4.Identity); + + Assert.True(env.SeenOutside); + Assert.Single(env.Portals); + Assert.Equal(0x0105u, env.Portals[0].OtherCellId); + Assert.Equal((ushort)7, env.Portals[0].OtherPortalId); + Assert.Equal(new[] { 0xA9B40105u, 0xA9B40106u }, env.StabList); + Assert.Equal(Vector3.Zero, env.LocalBoundsMin); // empty verts -> Zero fallback + Assert.Equal(Vector3.Zero, env.LocalBoundsMax); + Assert.Null(env.ContainmentBsp); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~EnvCellFromDatTests"` +Expected: FAIL — `EnvCell.FromDat` does not exist. + +- [ ] **Step 3: Add `FromDat` + private helper to `EnvCell.cs`** + +Add these `using`s to `EnvCell.cs`: `using System;`, `using DatReaderWriter.Enums;`. Then add inside the `EnvCell` class: + +```csharp + /// + /// Build an EnvCell from dat data. Mirrors the render derivation in + /// BuildLoadedCell (GameWindow.cs:5588-5704) so Core and render stay equivalent. + /// MUST be the physics-verbatim transform + /// (no +2 cm render lift — spec §5.1). + /// + public static EnvCell FromDat(uint id, DatReaderWriter.DBObjs.EnvCell datCell, + CellStruct cellStruct, Matrix4x4 worldTransform) + { + Matrix4x4.Invert(worldTransform, out var inverse); + + // LocalBounds from vertices (GameWindow.cs:5588-5602). + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + foreach (var kvp in cellStruct.VertexArray.Vertices) + { + var p = new Vector3(kvp.Value.Origin.X, kvp.Value.Origin.Y, kvp.Value.Origin.Z); + min = Vector3.Min(min, p); + max = Vector3.Max(max, p); + } + if (min.X == float.MaxValue) { min = Vector3.Zero; max = Vector3.Zero; } + + // Portals incl. OtherPortalId (GameWindow.cs:5612-5618). + var portals = new List(datCell.CellPortals.Count); + foreach (var p in datCell.CellPortals) + { + portals.Add(new CellPortal( + otherCellId: p.OtherCellId, + otherPortalId: p.OtherPortalId, + polygonId: p.PolygonId, + flags: (ushort)p.Flags, + polygonLocal: ResolvePortalPolygon(cellStruct, p.PolygonId))); + } + + // Stab list (landblock-prefixed) + SeenOutside (GameWindow.cs:5699-5704). + uint lbPrefix = id & 0xFFFF0000u; + var stab = new List(datCell.VisibleCells.Count); + foreach (var low in datCell.VisibleCells) stab.Add(lbPrefix | low); + bool seenOutside = datCell.Flags.HasFlag(EnvCellFlags.SeenOutside); + + return new EnvCell(id, worldTransform, inverse, min, max, portals, stab, + seenOutside, cellStruct.CellBSP); + } + + private static IReadOnlyList ResolvePortalPolygon(CellStruct cellStruct, ushort polygonId) + { + if (!cellStruct.Polygons.TryGetValue(polygonId, out var poly)) return Array.Empty(); + var verts = new List(poly.VertexIds.Count); + foreach (var vid in poly.VertexIds) + if (cellStruct.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) + verts.Add(new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z)); + return verts; + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~EnvCellFromDatTests"` +Expected: PASS. If it fails to compile on a dat field name, fix the initializer to match the generated type confirmed in Step 1 (do NOT change the assertions). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core/World/Cells/EnvCell.cs tests/AcDream.Core.Tests/World/Cells/EnvCellFromDatTests.cs +git commit -m "feat(core): UCG Stage 1 — EnvCell.FromDat derivation (mirrors BuildLoadedCell)" +``` + +--- + +## Task 4: `LandCell` (synthesize + 24 m quad `PointInCell`) + +**Files:** +- Create: `src/AcDream.Core/World/Cells/LandCell.cs` +- Test: `tests/AcDream.Core.Tests/World/Cells/LandCellTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +// tests/AcDream.Core.Tests/World/Cells/LandCellTests.cs +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.World.Cells; +using Xunit; + +namespace AcDream.Core.Tests.World.Cells; + +public class LandCellTests +{ + // Flat landblock: 81 height bytes all index 0, heightTable[0]=0 -> Z=0 everywhere. + private static TerrainSurface FlatTerrain() + => new TerrainSurface(new byte[81], new float[256], landblockX: 0, landblockY: 0); + + [Fact] + public void Synthesize_SetsCellIndicesAndQuadBounds() + { + var origin = new Vector3(1000f, 2000f, 0f); + // low cell id for (cx=2, cy=3) = 1 + 2*8 + 3 = 0x14 + var cell = LandCell.Synthesize(0xA9B40014u, FlatTerrain(), origin, cx: 2, cy: 3); + Assert.Equal(2, cell.Cx); + Assert.Equal(3, cell.Cy); + Assert.Equal(2 * 24f, cell.LocalBoundsMin.X); + Assert.Equal(3 * 24f, cell.LocalBoundsMin.Y); + Assert.Equal(3 * 24f, cell.LocalBoundsMax.X); + Assert.Equal(4 * 24f, cell.LocalBoundsMax.Y); + Assert.False(cell.IsEnv); // 0x14 < 0x100 + } + + [Fact] + public void PointInCell_TestsWorldXyAgainstThe24mQuad() + { + var origin = new Vector3(1000f, 2000f, 0f); + var cell = LandCell.Synthesize(0xA9B40014u, FlatTerrain(), origin, cx: 2, cy: 3); + // cell world XY quad: X in [1048,1072), Y in [2072,2096) + Assert.True(cell.PointInCell(new Vector3(1060f, 2080f, 12.3f))); // inside, any Z + Assert.False(cell.PointInCell(new Vector3(1000f, 2080f, 0f))); // X too low + Assert.False(cell.PointInCell(new Vector3(1060f, 2100f, 0f))); // Y too high + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~LandCellTests"` +Expected: FAIL — `LandCell` does not exist. + +- [ ] **Step 3: Write `LandCell`** + +```csharp +// src/AcDream.Core/World/Cells/LandCell.cs +using System; +using System.Numerics; +using AcDream.Core.Physics; // TerrainSurface + +namespace AcDream.Core.World.Cells; + +/// +/// Outdoor terrain cell — synthesized on demand from a landblock's +/// (retail CLandCell is positionally resolved, not stored). +/// Retail anchor: CLandCell (acclient.h:31886) / CSortCell (acclient.h:31880). +/// +public sealed class LandCell : ObjCell +{ + public const float CellSize = 24f; // TerrainSurface.CellSize (private const) mirrored + + public TerrainSurface Terrain { get; } + public int Cx { get; } + public int Cy { get; } + /// CSortCell building bridge ref (population logic is Stage 2). Always null in Stage 1. + public uint? BuildingCellId { get; } + + private readonly Vector3 _worldOrigin; + + private LandCell(uint id, TerrainSurface terrain, Vector3 worldOrigin, int cx, int cy, + Matrix4x4 worldTransform, Matrix4x4 inverseWorldTransform, + Vector3 localBoundsMin, Vector3 localBoundsMax) + : base(id, worldTransform, inverseWorldTransform, localBoundsMin, localBoundsMax, + Array.Empty(), Array.Empty(), seenOutside: false) + { + Terrain = terrain; Cx = cx; Cy = cy; _worldOrigin = worldOrigin; BuildingCellId = null; + } + + public static LandCell Synthesize(uint id, TerrainSurface terrain, Vector3 worldOrigin, int cx, int cy) + { + float ox = cx * CellSize, oy = cy * CellSize; + // Z bounds from the cell's 4 corner heights (local XY). + float z0 = terrain.SampleZ(ox, oy), z1 = terrain.SampleZ(ox + CellSize, oy); + float z2 = terrain.SampleZ(ox, oy + CellSize), z3 = terrain.SampleZ(ox + CellSize, oy + CellSize); + float zMin = MathF.Min(MathF.Min(z0, z1), MathF.Min(z2, z3)); + float zMax = MathF.Max(MathF.Max(z0, z1), MathF.Max(z2, z3)); + var min = new Vector3(ox, oy, zMin); + var max = new Vector3(ox + CellSize, oy + CellSize, zMax); + + var transform = Matrix4x4.CreateTranslation(worldOrigin); + Matrix4x4.Invert(transform, out var inverse); + return new LandCell(id, terrain, worldOrigin, cx, cy, transform, inverse, min, max); + } + + public override bool PointInCell(Vector3 worldPoint) + { + // Outdoor containment is the 24 m XY quad (Z follows terrain, not bounded here). + float lx = worldPoint.X - _worldOrigin.X; + float ly = worldPoint.Y - _worldOrigin.Y; + return lx >= Cx * CellSize && lx < (Cx + 1) * CellSize + && ly >= Cy * CellSize && ly < (Cy + 1) * CellSize; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~LandCellTests"` +Expected: PASS (2 cases). If `TerrainSurface(byte[], float[], uint, uint)` ctor differs, match the confirmed signature at `TerrainSurface.cs:35`. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core/World/Cells/LandCell.cs tests/AcDream.Core.Tests/World/Cells/LandCellTests.cs +git commit -m "feat(core): UCG Stage 1 — LandCell synthesized from TerrainSurface" +``` + +--- + +## Task 5: `CellGraph` (container + `GetVisible` + population API + inert `CurrCell`) + +**Files:** +- Create: `src/AcDream.Core/World/Cells/CellGraph.cs` +- Test: `tests/AcDream.Core.Tests/World/Cells/CellGraphTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +// tests/AcDream.Core.Tests/World/Cells/CellGraphTests.cs +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.World.Cells; +using Xunit; + +namespace AcDream.Core.Tests.World.Cells; + +public class CellGraphTests +{ + private static TerrainSurface FlatTerrain() => new TerrainSurface(new byte[81], new float[256]); + + private static EnvCell Env(uint id) => new EnvCell(id, Matrix4x4.Identity, Matrix4x4.Identity, + Vector3.Zero, new Vector3(10,10,10), System.Array.Empty(), + System.Array.Empty(), false, null); + + [Fact] + public void GetVisible_ZeroId_ReturnsNull() + => Assert.Null(new CellGraph().GetVisible(0u)); + + [Fact] + public void GetVisible_EnvId_ReturnsAddedEnvCell() + { + var g = new CellGraph(); + var env = Env(0xA9B40174u); + g.Add(env); + Assert.Same(env, g.GetVisible(0xA9B40174u)); + } + + [Fact] + public void GetVisible_UnknownEnvId_ReturnsNull() + => Assert.Null(new CellGraph().GetVisible(0xA9B40174u)); + + [Fact] + public void GetVisible_LandId_SynthesizesFromRegisteredTerrain() + { + var g = new CellGraph(); + g.RegisterTerrain(0xA9B40000u, FlatTerrain(), new Vector3(1000,2000,0)); + var cell = g.GetVisible(0xA9B40014u); // low 0x14 -> (cx=2,cy=3) + var land = Assert.IsType(cell); + Assert.Equal(2, land.Cx); + Assert.Equal(3, land.Cy); + } + + [Fact] + public void GetVisible_LandId_NoTerrain_ReturnsNull() + => Assert.Null(new CellGraph().GetVisible(0xA9B40014u)); + + [Fact] + public void RemoveLandblock_EvictsEnvAndTerrain() + { + var g = new CellGraph(); + g.Add(Env(0xA9B40174u)); + g.RegisterTerrain(0xA9B40000u, FlatTerrain(), Vector3.Zero); + g.RemoveLandblock(0xA9B40000u); + Assert.Null(g.GetVisible(0xA9B40174u)); + Assert.Null(g.GetVisible(0xA9B40014u)); + } + + [Fact] + public void Neighbor_ResolvesPortalOtherCellId() + { + var g = new CellGraph(); + var target = Env(0xA9B40175u); + g.Add(target); + var portal = new CellPortal(0xA9B40175u, 0, 0, 0); + Assert.Same(target, g.Neighbor(Env(0xA9B40174u), portal)); + } + + [Fact] + public void CurrCell_IsNull_InStage1() + => Assert.Null(new CellGraph().CurrCell); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~CellGraphTests"` +Expected: FAIL — `CellGraph` does not exist. + +- [ ] **Step 3: Write `CellGraph`** + +```csharp +// src/AcDream.Core/World/Cells/CellGraph.cs +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; // TerrainSurface + +namespace AcDream.Core.World.Cells; + +/// +/// The unified cell graph: the authoritative id->cell resolver and registry. +/// Built alongside the legacy render/physics cell systems in Stage 1 and consumed +/// by nobody (zero behavior change). Retail anchor: CObjCell::GetVisible (pseudo_c:308209). +/// Worker-thread populated; reads are concurrency-safe. +/// +public sealed class CellGraph +{ + private readonly ConcurrentDictionary _envCells = new(); + private readonly ConcurrentDictionary _terrain = new(); + + /// Player's current cell. Defined for Stage 2; INERT in Stage 1 (no writer). + public ObjCell? CurrCell { get; internal set; } + + public bool Contains(uint envCellId) => _envCells.ContainsKey(envCellId); + + public void Add(EnvCell cell) => _envCells.TryAdd(cell.Id, cell); + + /// Any id in the cell's landblock; masked to (id & 0xFFFF0000). + public void RegisterTerrain(uint landblockPrefix, TerrainSurface terrain, Vector3 worldOrigin) + => _terrain[landblockPrefix & 0xFFFF0000u] = (terrain, worldOrigin); + + public void RemoveLandblock(uint landblockPrefix) + { + uint lb = landblockPrefix & 0xFFFF0000u; + _terrain.TryRemove(lb, out _); + foreach (var id in new List(_envCells.Keys)) + if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _); + } + + /// The universal id->cell resolver (retail CObjCell::GetVisible). + public ObjCell? GetVisible(uint id) + { + if (id == 0u) return null; + if ((id & 0xFFFFu) >= 0x100u) + return _envCells.TryGetValue(id, out var env) ? env : null; + + uint low = id & 0xFFFFu; + if (low < 1u || low > 0x40u) return null; // valid outdoor cells: 0x01..0x40 + if (!_terrain.TryGetValue(id & 0xFFFF0000u, out var t)) return null; + int idx = (int)(low - 1u); + return LandCell.Synthesize(id, t.Terrain, t.Origin, idx / 8, idx % 8); + } + + public ObjCell? Neighbor(ObjCell cell, in CellPortal portal) => GetVisible(portal.OtherCellId); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~CellGraphTests"` +Expected: PASS (8 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core/World/Cells/CellGraph.cs tests/AcDream.Core.Tests/World/Cells/CellGraphTests.cs +git commit -m "feat(core): UCG Stage 1 — CellGraph resolver + registry + inert CurrCell" +``` + +--- + +## Task 6: Real-geometry fixture grounding test + +**Files:** +- Test: `tests/AcDream.Core.Tests/World/Cells/CellGraphFixtureTests.cs` + +> Grounds the type in a REAL Holtburg cottage cell using the canonical #98 fixture loader (mirrors `Issue98CellarUpReplayTests`). Fixtures hydrate `CellBSP=null` and carry no dat portals, so this asserts the robust facts: a real id resolves via `GetVisible`, the cell is env, and real geometry yields non-degenerate bounds. + +- [ ] **Step 1: Confirm the Resolved-vertex space, then write the failing test** + +Read `src/AcDream.Core/Physics/CellDump.cs` (`Hydrate`, ~L169-200) to confirm whether `CellPhysics.Resolved[*].Vertices` are cell-local or world (the bounds helper below treats them as cell-local, matching the cell's own `WorldTransform`). If world-space, drop the bounds assertion and keep the resolve/identity asserts. + +```csharp +// tests/AcDream.Core.Tests/World/Cells/CellGraphFixtureTests.cs +using System; +using System.IO; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.World.Cells; +using Xunit; + +namespace AcDream.Core.Tests.World.Cells; + +public class CellGraphFixtureTests +{ + private const uint CellarCellId = 0xA9B40147u; // #98 cottage cellar fixture + + private static string FixtureDir() + { + var dir = AppContext.BaseDirectory; + while (dir is not null && !File.Exists(Path.Combine(dir, "AcDream.slnx"))) + dir = Directory.GetParent(dir)?.FullName; + Assert.NotNull(dir); + return Path.Combine(dir!, "tests", "AcDream.Core.Tests", "Fixtures", "issue98"); + } + + private static EnvCell EnvFromFixture(uint cellId) + { + var path = Path.Combine(FixtureDir(), $"0x{cellId:X8}.json"); + Assert.True(File.Exists(path), $"missing fixture {path}"); + var cp = CellDumpSerializer.Hydrate(CellDumpSerializer.Read(path)); + + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + foreach (var poly in cp.Resolved.Values) + foreach (var v in poly.Vertices) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); } + if (min.X == float.MaxValue) { min = Vector3.Zero; max = Vector3.Zero; } + + return new EnvCell(cellId, cp.WorldTransform, cp.InverseWorldTransform, min, max, + Array.Empty(), Array.Empty(), false, cp.CellBSP); + } + + [Fact] + public void RealCottageCell_ResolvesViaGetVisible_AndIsEnv() + { + var g = new CellGraph(); + var env = EnvFromFixture(CellarCellId); + g.Add(env); + + var resolved = g.GetVisible(CellarCellId); + Assert.Same(env, resolved); + Assert.True(resolved!.IsEnv); + Assert.True(env.LocalBoundsMax.X > env.LocalBoundsMin.X, "non-degenerate bounds X"); + Assert.True(env.LocalBoundsMax.Y > env.LocalBoundsMin.Y, "non-degenerate bounds Y"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails (or passes if types compile)** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~CellGraphFixtureTests"` +Expected: FAIL first if `CellDumpSerializer.Hydrate`/`Read` signatures differ — confirm against `CellDump.cs:153,169`; once correct, PASS. (No production code changes in this task — it exercises Task 1-5 types against real data.) + +- [ ] **Step 3: (No new production code.)** If the test reveals a real bug in the Task 1-5 types, fix it there and re-run that task's tests too. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~CellGraphFixtureTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/AcDream.Core.Tests/World/Cells/CellGraphFixtureTests.cs +git commit -m "test(core): UCG Stage 1 — real cottage-cell fixture grounding" +``` + +--- + +## Task 7: Production population wiring (inert) + +**Files:** +- Modify: `src/AcDream.Core/Physics/PhysicsDataCache.cs` +- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs` +- Test: `tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs` + +> The graph is populated in the running client but consumed by nobody. Key requirement: the env-cell add runs for ALL cells, **including those `CacheCellStruct` drops for a null physics BSP** (spec §5.1). So it goes at the very top of `CacheCellStruct`, before both the idempotency guard (L158) and the null-BSP return (L159). + +- [ ] **Step 1: Confirm the host APIs** + +Read: `PhysicsDataCache.cs:155-202` (CacheCellStruct), the `PhysicsDataCache` field region near L21-24 (to add the `CellGraph` property), and `PhysicsEngine.cs:47-68` (the `LandblockPhysics` record, `AddLandblock`, `RemoveLandblock`) plus how `PhysicsEngine` references the data cache (the field/property name — used by `ResolveCellId` at ~L286). Confirm `AddLandblock(uint id, TerrainSurface terrain, ..., float worldOffsetX, float worldOffsetY)` and the `lb.LandblockId` value passed at `GameWindow.cs:5909` is in the `(id & 0xFFFF0000)`-comparable space; if it is a bare 16-bit value, shift it (`<< 16`) at the call site so it matches `EnvCell` ids' `lbPrefix`. + +- [ ] **Step 2: Write the failing integration test** + +```csharp +// tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.World.Cells; +using DatReaderWriter.Types; +using Xunit; +using DatEnvCell = DatReaderWriter.DBObjs.EnvCell; + +namespace AcDream.Core.Tests.Physics; + +public class CellGraphPopulationTests +{ + [Fact] + public void CacheCellStruct_AddsEnvCellToGraph_EvenWhenPhysicsBspIsNull() + { + var cache = new PhysicsDataCache(); + var cellStruct = new CellStruct + { + VertexArray = new VertexArray { Vertices = new Dictionary() }, + Polygons = new Dictionary(), + CellBSP = null, + PhysicsBSP = null, // <- triggers the L159 drop from _cellStruct + }; + var dat = new DatEnvCell + { + Flags = (DatReaderWriter.Enums.EnvCellFlags)0, + CellPortals = new List(), + VisibleCells = new List(), + }; + + cache.CacheCellStruct(0xA9B40174u, dat, cellStruct, Matrix4x4.Identity); + + // Dropped from the physics cache (null BSP) ... + Assert.Null(cache.GetCellStruct(0xA9B40174u)); + // ... but PRESENT in the unified graph (spec §5.1). + Assert.NotNull(cache.CellGraph.GetVisible(0xA9B40174u)); + Assert.IsType(cache.CellGraph.GetVisible(0xA9B40174u)); + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~CellGraphPopulationTests"` +Expected: FAIL — `PhysicsDataCache.CellGraph` does not exist. + +- [ ] **Step 4: Add the `CellGraph` property to `PhysicsDataCache`** + +Add near the other fields (after the `_buildings` dictionary, ~L24), with `using AcDream.Core.World.Cells;` at the top of the file: + +```csharp + /// + /// UCG Stage 1: the unified cell graph, built alongside the legacy cell caches. + /// Consumed by nobody this stage (zero behavior change). + /// + public CellGraph CellGraph { get; } = new(); +``` + +- [ ] **Step 5: Add the env-cell graph hook at the TOP of `CacheCellStruct`** + +Insert as the FIRST statements of `CacheCellStruct` (before `if (_cellStruct.ContainsKey(envCellId)) return;` at L158): + +```csharp + // UCG Stage 1: register in the unified graph for ALL cells — before the + // idempotency + null-BSP guards below, so BSP-less cells are still included. + if (!CellGraph.Contains(envCellId)) + CellGraph.Add(EnvCell.FromDat(envCellId, envCell, cellStruct, worldTransform)); +``` + +- [ ] **Step 6: Add the terrain hooks to `PhysicsEngine`** + +In `AddLandblock` (after the existing `_landblocks[...] = ...` assignment), add (replace `DataCache` with the confirmed field name from Step 1; null-guard it): + +```csharp + // UCG Stage 1: mirror the terrain into the unified graph (inert this stage). + DataCache?.CellGraph.RegisterTerrain(id, terrain, new System.Numerics.Vector3(worldOffsetX, worldOffsetY, 0f)); +``` + +In `RemoveLandblock` (alongside the existing `_landblocks.Remove(id)`), add: + +```csharp + DataCache?.CellGraph.RemoveLandblock(id); +``` + +Add `using AcDream.Core.World.Cells;` to `PhysicsEngine.cs` if not already present. (If `AddLandblock`'s id is bare-16-bit per Step 1, pass `id << 16` to both `RegisterTerrain` and `RemoveLandblock` so the key matches `EnvCell` lbPrefix.) + +- [ ] **Step 7: Run the test + the full Core suite** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~CellGraphPopulationTests"` +Expected: PASS. +Then run the whole suite (see Task 8) to confirm no regressions. + +- [ ] **Step 8: Commit** + +```bash +git add src/AcDream.Core/Physics/PhysicsDataCache.cs src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs +git commit -m "feat(core): UCG Stage 1 — populate CellGraph from CacheCellStruct + AddLandblock (inert)" +``` + +--- + +## Task 8: Full verification + zero-behavior-change confirmation + +**Files:** none (verification only). + +- [ ] **Step 1: Build both projects** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: `Build succeeded. 0 Error(s)`. (Building App transitively builds Core; confirms the new Core types don't break the App layer and no App/GL dependency leaked into Core.) + +- [ ] **Step 2: Run the full Core test suite** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug` +Expected: PASS. The new World/Cells + population tests pass. Pre-existing failures must be a strict subset of the documented static-leak flakiness baseline (CLAUDE.md) — compare against a baseline run on `e8c7164` if any failures appear; no NEW failures introduced by Stage 1. + +- [ ] **Step 3: Confirm zero behavior change (no consumer)** + +Verify by inspection that nothing reads `PhysicsDataCache.CellGraph` outside the new code: `rg "\.CellGraph" src` should show only the population writes in `PhysicsDataCache.cs`/`PhysicsEngine.cs` (no reads in render/movement). The graph is write-only this stage. + +- [ ] **Step 4: (Optional) live smoke — identical render** + +Per CLAUDE.md "Running the client", launch and confirm the Holtburg cottage renders **exactly as baseline** (still broken — the indoor fix is Stage 3; the point is *no change*). Stage 1 has no visual change to verify, so this is optional confidence only. + +- [ ] **Step 5: Update the roadmap + commit** + +Add the UCG program + Stage 1 (shipped) to `docs/plans/2026-04-11-roadmap.md` (assign the phase id with the user) and flip Stage 1 to shipped. Commit: + +```bash +git add docs/plans/2026-04-11-roadmap.md +git commit -m "docs(roadmap): UCG Stage 1 (ObjCell scaffold) shipped" +``` + +--- + +## Self-Review + +**Spec coverage** (against `2026-06-02-unified-cell-graph-stage1-design.md`): +- §5.1 Core ownership / no App-GL dep → Tasks 1-5 (Core namespace) + Task 8 Step 1/3. ✓ +- §5.1 +2 cm lift resolved → `FromDat` uses physics-verbatim transform (Task 3); fixtures encode verbatim. ✓ +- §5.1 null-BSP inclusion → Task 7 Step 5 (add before L159) + the explicit null-BSP test (Task 7 Step 2). ✓ +- §5.2 types (`ObjCell`/`EnvCell`/`LandCell`/`CellPortal`/`CellGraph` + `GetVisible` + inert `CurrCell`) → Tasks 1-5. ✓ +- §5.3 population (CacheCellStruct + AddLandblock; LandCell synthesized; all cells) → Task 7. ✓ +- §5.4 boundary (consumed by nobody) → Task 8 Step 3. ✓ +- §6 tests (GetVisible dispatch, topology/Neighbor, PointInCell AABB, bounds, population/eviction, real fixture) → Tasks 1-7. ✓ (Portal `OtherPortalId` reciprocity covered by synthetic `FromDat` test, Task 3, since fixtures lack it.) + +**Placeholder scan:** none — every code step has complete code. The two "confirm the generated/host API" steps (Task 3 Step 1, Task 7 Step 1) are verification-before-use, with the concrete code supplied. + +**Type consistency:** `EnvCell.FromDat`, `CellGraph.Add/Contains/RegisterTerrain/RemoveLandblock/GetVisible/Neighbor`, `LandCell.Synthesize`, `CellPortal(otherCellId, otherPortalId, polygonId, flags, polygonLocal)`, `ObjCell.IsEnv` — names match across all tasks. `PhysicsDataCache.CellGraph` consistent in Tasks 7-8. + +**Known follow-on (not Stage 1):** the `EnvCell.PointInCell` BSP branch is unit-covered only via delegation (fixtures are BSP-less); it's validated end-to-end when dat cells flow at Stage 3.