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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-02 08:46:27 +02:00
parent e8c7164ad9
commit bd0244f203

View file

@ -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) <noreply@anthropic.com>` (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~<Class>.<Method>"`.
- 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<CellPortal>(), System.Array.Empty<uint>(), 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;
/// <summary>
/// Unified cell-to-cell portal edge. Superset of the three legacy portal types
/// (render <c>CellPortalInfo</c>, physics <c>PortalInfo</c>, <c>PortalPlane</c>).
/// Retail anchor: CCellPortal (acclient.h:32300).
/// </summary>
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; }
/// <summary>Matches the physics <c>PortalInfo.PortalSide</c> convention (PortalInfo.cs:44).</summary>
public bool PortalSide => (Flags & 0x2) == 0;
/// <summary>Cell-local portal polygon vertices. Carried now; consumed by PView at Stage 3.</summary>
public IReadOnlyList<Vector3> PolygonLocal { get; }
public CellPortal(uint otherCellId, ushort otherPortalId, ushort polygonId, ushort flags,
IReadOnlyList<Vector3>? polygonLocal = null)
{
OtherCellId = otherCellId;
OtherPortalId = otherPortalId;
PolygonId = polygonId;
Flags = flags;
PolygonLocal = polygonLocal ?? Array.Empty<Vector3>();
}
}
```
- [ ] **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;
/// <summary>
/// Base for every cell the player can stand in. Retail anchor: CObjCell
/// (acclient.h:30915). The id magnitude is the type discriminator
/// (<see cref="IsEnv"/>): low-16 &gt;= 0x100 =&gt; indoor <see cref="EnvCell"/>,
/// else outdoor <see cref="LandCell"/>.
/// </summary>
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<CellPortal> Portals { get; }
public IReadOnlyList<uint> StabList { get; }
public bool SeenOutside { get; }
/// <summary>Retail magnitude dispatch (CObjCell::GetVisible, pseudo_c:308215).</summary>
public bool IsEnv => (Id & 0xFFFFu) >= 0x100u;
protected ObjCell(uint id, Matrix4x4 worldTransform, Matrix4x4 inverseWorldTransform,
Vector3 localBoundsMin, Vector3 localBoundsMax,
IReadOnlyList<CellPortal> portals, IReadOnlyList<uint> stabList,
bool seenOutside)
{
Id = id;
WorldTransform = worldTransform;
InverseWorldTransform = inverseWorldTransform;
LocalBoundsMin = localBoundsMin;
LocalBoundsMax = localBoundsMax;
Portals = portals;
StabList = stabList;
SeenOutside = seenOutside;
}
/// <summary>Retail CObjCell::point_in_cell (vtable +0x84). Is a world point inside this cell?</summary>
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) <noreply@anthropic.com>"
```
---
## 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<CellPortal>(), System.Array.Empty<uint>(),
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;
/// <summary>Indoor room cell. Retail anchor: CEnvCell (acclient.h:32072).</summary>
public sealed class EnvCell : ObjCell
{
/// <summary>Cell-containment BSP (retail CellStruct.CellBSP). Null =&gt; AABB fallback.</summary>
public CellBSPTree? ContainmentBsp { get; }
public EnvCell(uint id, Matrix4x4 worldTransform, Matrix4x4 inverseWorldTransform,
Vector3 localBoundsMin, Vector3 localBoundsMax,
IReadOnlyList<CellPortal> portals, IReadOnlyList<uint> 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<ushort, Vertex>() }, // empty -> bounds fallback
Polygons = new Dictionary<ushort, Polygon>(),
CellBSP = null,
};
var dat = new DatEnvCell
{
Flags = EnvCellFlags.SeenOutside,
CellPortals = new List<DatCellPortal>
{
new() { OtherCellId = 0x0105, PolygonId = 0, OtherPortalId = 7, Flags = (PortalFlags)0 },
},
VisibleCells = new List<ushort> { 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
/// <summary>
/// Build an EnvCell from dat data. Mirrors the render derivation in
/// BuildLoadedCell (GameWindow.cs:5588-5704) so Core and render stay equivalent.
/// <paramref name="worldTransform"/> MUST be the physics-verbatim transform
/// (no +2 cm render lift — spec §5.1).
/// </summary>
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<CellPortal>(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<uint>(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<Vector3> ResolvePortalPolygon(CellStruct cellStruct, ushort polygonId)
{
if (!cellStruct.Polygons.TryGetValue(polygonId, out var poly)) return Array.Empty<Vector3>();
var verts = new List<Vector3>(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;
/// <summary>
/// Outdoor terrain cell — synthesized on demand from a landblock's
/// <see cref="TerrainSurface"/> (retail CLandCell is positionally resolved, not stored).
/// Retail anchor: CLandCell (acclient.h:31886) / CSortCell (acclient.h:31880).
/// </summary>
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; }
/// <summary>CSortCell building bridge ref (population logic is Stage 2). Always null in Stage 1.</summary>
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<CellPortal>(), Array.Empty<uint>(), 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<CellPortal>(),
System.Array.Empty<uint>(), 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<LandCell>(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;
/// <summary>
/// The unified cell graph: the authoritative id-&gt;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.
/// </summary>
public sealed class CellGraph
{
private readonly ConcurrentDictionary<uint, EnvCell> _envCells = new();
private readonly ConcurrentDictionary<uint, (TerrainSurface Terrain, Vector3 Origin)> _terrain = new();
/// <summary>Player's current cell. Defined for Stage 2; INERT in Stage 1 (no writer).</summary>
public ObjCell? CurrCell { get; internal set; }
public bool Contains(uint envCellId) => _envCells.ContainsKey(envCellId);
public void Add(EnvCell cell) => _envCells.TryAdd(cell.Id, cell);
/// <param name="landblockPrefix">Any id in the cell's landblock; masked to (id &amp; 0xFFFF0000).</param>
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<uint>(_envCells.Keys))
if ((id & 0xFFFF0000u) == lb) _envCells.TryRemove(id, out _);
}
/// <summary>The universal id-&gt;cell resolver (retail CObjCell::GetVisible).</summary>
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<CellPortal>(), Array.Empty<uint>(), 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<ushort, Vertex>() },
Polygons = new Dictionary<ushort, Polygon>(),
CellBSP = null,
PhysicsBSP = null, // <- triggers the L159 drop from _cellStruct
};
var dat = new DatEnvCell
{
Flags = (DatReaderWriter.Enums.EnvCellFlags)0,
CellPortals = new List<DatReaderWriter.Types.CellPortal>(),
VisibleCells = new List<ushort>(),
};
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<EnvCell>(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
/// <summary>
/// UCG Stage 1: the unified cell graph, built alongside the legacy cell caches.
/// Consumed by nobody this stage (zero behavior change).
/// </summary>
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.