From af68c56b916d964daf1cacdeab104fed09dc67b1 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Apr 2026 17:27:53 +0200 Subject: [PATCH] docs: phase 2 implementation plan (tasks 1-10 full, 11-18 sketch) Tasks 1-10 are fully specified TDD-bite-sized and cover Phase 2a: scaffold + GfxObjMesh.Build + SurfaceDecoder + LandblockLoader + SetupMesh.Flatten + WorldView + TextureCache + StaticMeshRenderer + debug + visual verification. End state: Holtburg terrain with buildings rendered and at least some textures resolved. Tasks 11-18 are sketches for Phase 2b in a future session: terrain atlas, 3x3 neighbor rendering, ICamera/FlyCamera/CameraController, and the IGameState/IEvents plugin API growth. These will be re-planned with fresh context once Phase 2a is known to work. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-10-phase-2-static-meshes-plan.md | 2139 +++++++++++++++++ 1 file changed, 2139 insertions(+) create mode 100644 docs/plans/2026-04-10-phase-2-static-meshes-plan.md diff --git a/docs/plans/2026-04-10-phase-2-static-meshes-plan.md b/docs/plans/2026-04-10-phase-2-static-meshes-plan.md new file mode 100644 index 0000000..6bd015c --- /dev/null +++ b/docs/plans/2026-04-10-phase-2-static-meshes-plan.md @@ -0,0 +1,2139 @@ +# Phase 2 Implementation Plan — Static Meshes, Textures, Neighbor Landblocks + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Extend Phase 1's single flat-shaded Holtburg landblock into a recognizable textured 3×3-landblock world populated with buildings and static objects, explorable by both orbit and free-fly cameras, with a minimal plugin API exposing the world entity list. + +**Architecture:** Add three new namespaces under `AcDream.Core` (`World/`, `Meshing/`, `Textures/`) for pure-CPU TDD work; add `StaticMeshRenderer`, `TextureCache`, `FlyCamera`, and an `ICamera` abstraction under `AcDream.App.Rendering`; grow `AcDream.Plugin.Abstractions` with `IGameState` + `IEvents`. Meshes are deduplicated by GfxObj id. A GfxObj produces one sub-mesh per referenced Surface because AC allows multiple surfaces per object. Textures are deduplicated by Surface id in a GL-handle cache. BCnEncoder.Net decodes DXT/BCn. + +**Tech Stack:** .NET 10, Silk.NET 2.23.0 (OpenGL 4.3 core), Chorizite.DatReaderWriter 2.1.4, BCnEncoder.Net 2.2.1 (new), xUnit. + +**Context for the engineer:** The repo is at commit `b72851a` on `main` with Phase 1 merged. Read `docs/plans/2026-04-10-phase-2-static-meshes-design.md` first. The Phase 1 plan at `docs/plans/2026-04-10-phase-1-terrain-and-plugin-scaffold.md` is the reference template for task granularity and tone. The retail AC install with dats lives at `references/Asheron's Call/` (gitignored); `ACDREAM_DAT_DIR` or argv[0] points to it. + +**Work branch:** `phase-2/static-meshes-and-textures` (to be created). Do NOT commit to `main` directly. + +**Testing philosophy:** TDD the pure-CPU pieces (mesh builder, surface decoder, landblock loader, world view, setup flatten). Manual smoke the GL-coupled pieces (mesh renderer, texture cache, shaders, cameras). 17 tests inherited from Phase 1 must stay green throughout. + +**Commit cadence:** One commit per task. Never batch. + +**Stopping point for this session:** After **Task 10** — textured buildings on Holtburg's single landblock, orbit camera only. Tasks 11–18 will be re-planned and executed in a follow-up session (Phase 2b). The sketches at the end of this doc are rough — do NOT treat them as authoritative. + +--- + +## Task 1: Scaffold — new namespaces + branch + package + +**Files:** +- Create branch: `phase-2/static-meshes-and-textures` +- Create: `src/AcDream.Core/World/.gitkeep` +- Create: `src/AcDream.Core/Meshing/.gitkeep` +- Create: `src/AcDream.Core/Textures/.gitkeep` +- Modify: `src/AcDream.Core/AcDream.Core.csproj` (add `BCnEncoder.Net` 2.2.1 package) + +**Step 1: Branch off main** + +```bash +git checkout main +git pull --rebase 2>/dev/null || true +git checkout -b phase-2/static-meshes-and-textures +``` + +**Step 2: Create empty directories** + +The `.gitkeep` placeholder files ensure git tracks the empty directories. They'll be removed naturally as real code lands in Tasks 2-5. + +```bash +mkdir -p src/AcDream.Core/World src/AcDream.Core/Meshing src/AcDream.Core/Textures +touch src/AcDream.Core/World/.gitkeep +touch src/AcDream.Core/Meshing/.gitkeep +touch src/AcDream.Core/Textures/.gitkeep +``` + +**Step 3: Add BCnEncoder.Net to AcDream.Core** + +Edit `src/AcDream.Core/AcDream.Core.csproj`, add inside the existing ItemGroup that holds PackageReferences: + +```xml + +``` + +The final ItemGroup should look like: + +```xml + + + + + +``` + +**Step 4: Build + test to confirm nothing regressed** + +```bash +dotnet restore +dotnet build +``` +Expected: 0 warnings, 0 errors. + +```bash +dotnet test +``` +Expected: `Passed: 17, Failed: 0`. + +**Step 5: Commit** + +```bash +git add src/AcDream.Core/AcDream.Core.csproj src/AcDream.Core/World src/AcDream.Core/Meshing src/AcDream.Core/Textures +git commit -m "chore(core): scaffold World/Meshing/Textures + add BCnEncoder.Net" +``` + +--- + +## Task 2: `GfxObjMesh.Build` — the hardest piece, TDD + +**Files:** +- Create: `src/AcDream.Core/Meshing/GfxObjSubMesh.cs` +- Create: `src/AcDream.Core/Meshing/GfxObjMesh.cs` +- Create: `tests/AcDream.Core.Tests/Meshing/GfxObjMeshTests.cs` +- Delete (if present): `src/AcDream.Core/Meshing/.gitkeep` + +**Background you need to read before coding.** + +DatReaderWriter's `GfxObj` has these relevant fields (from `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/GfxObj.generated.cs`): + +```csharp +public partial class GfxObj : DBObj { + public List> Surfaces = []; // multiple surfaces allowed + public VertexArray VertexArray; // .Vertices is Dictionary + public Dictionary Polygons = []; // sparse, keyed by polygon id + // ... other fields we don't need ... +} +``` + +`SWVertex` (from `Generated/Types/SWVertex.generated.cs`): +```csharp +public partial class SWVertex : IDatObjType { + public Vector3 Origin; + public Vector3 Normal; + public List UVs = []; // a single position can have multiple UV sets +} +``` + +`Vec2Duv`: +```csharp +public partial class Vec2Duv : IDatObjType { + public float U; + public float V; +} +``` + +`Polygon` (from `Generated/Types/Polygon.generated.cs`): +```csharp +public partial class Polygon : IDatObjType { + public StipplingType Stippling; + public CullMode SidesType; + public short PosSurface; // index into GfxObj.Surfaces + public short NegSurface; // backface surface; ignored in Phase 2 + public List VertexIds = []; // position indices into VertexArray.Vertices + public List PosUVIndices = []; // for each VertexIds[i], which UV slot to use + public List NegUVIndices = []; // backface UVs; ignored in Phase 2 +} +``` + +`QualifiedDataId` has an implicit conversion to `uint` that gives you the dat id. Use it to get the `Surface` id for each polygon: `(uint)gfxObj.Surfaces[polygon.PosSurface]`. + +**The algorithm:** + +1. Group polygons by `PosSurface` index. +2. For each group, emit one `GfxObjSubMesh`. +3. Within a group, build an output-vertex deduplication map keyed by `(posIdx, uvIdx)`: + - For each polygon in the group, for each `i` in `0..VertexIds.Count-1`: + - `posIdx = VertexIds[i]` + - `uvIdx = PosUVIndices[i]` + - Look up or create an output vertex: `position = VertexArray.Vertices[posIdx].Origin`, `normal = VertexArray.Vertices[posIdx].Normal`, `texcoord = VertexArray.Vertices[posIdx].UVs[uvIdx]` → `new Vector2(U, V)`. + - After collecting the polygon's output vertex indices, fan-triangulate: `(v[0], v[1], v[2]), (v[0], v[2], v[3]), ...` for polygons with more than 3 vertices. +4. Return one `GfxObjSubMesh` per surface group. The surface id is `(uint)gfxObj.Surfaces[posSurfaceIndex]`. + +**Edge cases:** + +- A polygon with fewer than 3 vertices is degenerate — skip it (no triangles). +- A polygon whose `PosSurface` index is out of range of `GfxObj.Surfaces` → skip it. +- A `VertexIds[i]` that doesn't exist in `VertexArray.Vertices` → skip that vertex (whole polygon may then be degenerate). +- A `PosUVIndices[i]` that's out of range of that vertex's `UVs` list → fall back to `(0, 0)` texcoord. + +**Don't over-engineer:** do NOT handle `NegSurface`/`NegUVIndices`. Do NOT implement backface culling. Do NOT care about `CullMode`, `Stippling`, or any other polygon flag. + +### Step 1: Define the output types + +Create `src/AcDream.Core/Meshing/GfxObjSubMesh.cs`: + +```csharp +using AcDream.Core.Terrain; + +namespace AcDream.Core.Meshing; + +/// +/// One sub-mesh of a GfxObj: a vertex+index buffer that uses a single Surface. +/// A GfxObj with multiple surfaces produces multiple sub-meshes. +/// +public sealed record GfxObjSubMesh( + uint SurfaceId, + Vertex[] Vertices, + uint[] Indices); +``` + +### Step 2: Write failing tests + +Create `tests/AcDream.Core.Tests/Meshing/GfxObjMeshTests.cs`. This is a long file because the algorithm has many edge cases. Build a helper `BuildFixture` method for constructing synthetic `GfxObj` instances. + +```csharp +using System.Numerics; +using AcDream.Core.Meshing; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Lib; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.Meshing; + +public class GfxObjMeshTests +{ + /// + /// Build a minimal GfxObj fixture with a single triangle using surface index 0. + /// Three unique positions, one UV slot each. + /// + private static GfxObj BuildSingleTriangle() + { + var gfx = new GfxObj + { + Surfaces = { 0x08000000u }, // synthetic surface id + VertexArray = new VertexArray + { + VertexType = VertexType.CSWVertexType, + Vertices = + { + [0] = new SWVertex + { + Origin = new Vector3(0, 0, 0), + Normal = new Vector3(0, 0, 1), + UVs = { new Vec2Duv { U = 0, V = 0 } }, + }, + [1] = new SWVertex + { + Origin = new Vector3(1, 0, 0), + Normal = new Vector3(0, 0, 1), + UVs = { new Vec2Duv { U = 1, V = 0 } }, + }, + [2] = new SWVertex + { + Origin = new Vector3(0, 1, 0), + Normal = new Vector3(0, 0, 1), + UVs = { new Vec2Duv { U = 0, V = 1 } }, + }, + }, + }, + Polygons = + { + [0] = new Polygon + { + PosSurface = 0, + NegSurface = -1, + VertexIds = { 0, 1, 2 }, + PosUVIndices = { 0, 0, 0 }, + }, + }, + }; + return gfx; + } + + [Fact] + public void Build_SingleTriangle_ProducesOneSubMeshOneTriangle() + { + var gfx = BuildSingleTriangle(); + + var subs = GfxObjMesh.Build(gfx); + + var sub = Assert.Single(subs); + Assert.Equal(0x08000000u, sub.SurfaceId); + Assert.Equal(3, sub.Vertices.Length); + Assert.Equal(3, sub.Indices.Length); // one triangle, 3 indices + } + + [Fact] + public void Build_SingleTriangle_CopiesPositionsNormalsAndUVs() + { + var gfx = BuildSingleTriangle(); + + var sub = GfxObjMesh.Build(gfx).Single(); + + // Indices point at unique vertices; collect them in order. + var vAtIdx0 = sub.Vertices[sub.Indices[0]]; + var vAtIdx1 = sub.Vertices[sub.Indices[1]]; + var vAtIdx2 = sub.Vertices[sub.Indices[2]]; + + Assert.Equal(new Vector3(0, 0, 0), vAtIdx0.Position); + Assert.Equal(new Vector3(1, 0, 0), vAtIdx1.Position); + Assert.Equal(new Vector3(0, 1, 0), vAtIdx2.Position); + + Assert.Equal(new Vector3(0, 0, 1), vAtIdx0.Normal); + Assert.Equal(new Vector2(0, 0), vAtIdx0.TexCoord); + Assert.Equal(new Vector2(1, 0), vAtIdx1.TexCoord); + Assert.Equal(new Vector2(0, 1), vAtIdx2.TexCoord); + } + + [Fact] + public void Build_Quad_IsTriangulatedAsFan() + { + // Single quad polygon with 4 vertices -> 2 triangles, 6 indices. + var gfx = new GfxObj + { + Surfaces = { 0x08000000u }, + VertexArray = new VertexArray + { + Vertices = + { + [0] = new SWVertex { Origin = new(0, 0, 0), UVs = { new Vec2Duv() } }, + [1] = new SWVertex { Origin = new(1, 0, 0), UVs = { new Vec2Duv() } }, + [2] = new SWVertex { Origin = new(1, 1, 0), UVs = { new Vec2Duv() } }, + [3] = new SWVertex { Origin = new(0, 1, 0), UVs = { new Vec2Duv() } }, + }, + }, + Polygons = + { + [0] = new Polygon + { + PosSurface = 0, + VertexIds = { 0, 1, 2, 3 }, + PosUVIndices = { 0, 0, 0, 0 }, + }, + }, + }; + + var sub = GfxObjMesh.Build(gfx).Single(); + + Assert.Equal(4, sub.Vertices.Length); + Assert.Equal(6, sub.Indices.Length); // 2 triangles + } + + [Fact] + public void Build_SamePositionDifferentUVs_DuplicatesOutputVertices() + { + // One vertex has two different UV slots. Each (posIdx, uvIdx) combo + // becomes a distinct output vertex. + var gfx = new GfxObj + { + Surfaces = { 0x08000000u }, + VertexArray = new VertexArray + { + Vertices = + { + [0] = new SWVertex + { + Origin = new(0, 0, 0), + UVs = + { + new Vec2Duv { U = 0, V = 0 }, + new Vec2Duv { U = 1, V = 1 }, + }, + }, + [1] = new SWVertex { Origin = new(1, 0, 0), UVs = { new Vec2Duv() } }, + [2] = new SWVertex { Origin = new(0, 1, 0), UVs = { new Vec2Duv() } }, + }, + }, + Polygons = + { + [0] = new Polygon + { + PosSurface = 0, + VertexIds = { 0, 1, 2 }, + PosUVIndices = { 0, 0, 0 }, + }, + [1] = new Polygon + { + PosSurface = 0, + VertexIds = { 0, 1, 2 }, + PosUVIndices = { 1, 0, 0 }, // same positions, different UV on vert 0 + }, + }, + }; + + var sub = GfxObjMesh.Build(gfx).Single(); + + // vert 0 has two different UV slots → 2 output vertices for pos 0 + // vert 1 + 2 unique → 2 more output vertices + // total: 4 output vertices + Assert.Equal(4, sub.Vertices.Length); + Assert.Equal(6, sub.Indices.Length); // 2 triangles + } + + [Fact] + public void Build_MultipleSurfaces_ProducesMultipleSubMeshes() + { + // 2 polygons, 2 surfaces → 2 sub-meshes. + var gfx = new GfxObj + { + Surfaces = { 0x08000001u, 0x08000002u }, + VertexArray = new VertexArray + { + Vertices = + { + [0] = new SWVertex { Origin = new(0, 0, 0), UVs = { new Vec2Duv() } }, + [1] = new SWVertex { Origin = new(1, 0, 0), UVs = { new Vec2Duv() } }, + [2] = new SWVertex { Origin = new(0, 1, 0), UVs = { new Vec2Duv() } }, + [3] = new SWVertex { Origin = new(1, 1, 0), UVs = { new Vec2Duv() } }, + }, + }, + Polygons = + { + [0] = new Polygon + { + PosSurface = 0, + VertexIds = { 0, 1, 2 }, + PosUVIndices = { 0, 0, 0 }, + }, + [1] = new Polygon + { + PosSurface = 1, + VertexIds = { 1, 3, 2 }, + PosUVIndices = { 0, 0, 0 }, + }, + }, + }; + + var subs = GfxObjMesh.Build(gfx); + + Assert.Equal(2, subs.Count); + Assert.Contains(subs, s => s.SurfaceId == 0x08000001u); + Assert.Contains(subs, s => s.SurfaceId == 0x08000002u); + } + + [Fact] + public void Build_DegeneratePolygonWithTwoVertices_Skipped() + { + var gfx = new GfxObj + { + Surfaces = { 0x08000000u }, + VertexArray = new VertexArray + { + Vertices = + { + [0] = new SWVertex { Origin = new(0, 0, 0), UVs = { new Vec2Duv() } }, + [1] = new SWVertex { Origin = new(1, 0, 0), UVs = { new Vec2Duv() } }, + }, + }, + Polygons = + { + [0] = new Polygon + { + PosSurface = 0, + VertexIds = { 0, 1 }, + PosUVIndices = { 0, 0 }, + }, + }, + }; + + var subs = GfxObjMesh.Build(gfx); + + Assert.Empty(subs); // no valid polygons → no sub-meshes + } +} +``` + +### Step 3: Run tests to verify they FAIL + +```bash +dotnet test --filter "FullyQualifiedName~GfxObjMeshTests" +``` + +Expected: compile errors for `GfxObjMesh`. RED state. + +### Step 4: Implement + +Create `src/AcDream.Core/Meshing/GfxObjMesh.cs`: + +```csharp +using System.Numerics; +using AcDream.Core.Terrain; +using DatReaderWriter.DBObjs; + +namespace AcDream.Core.Meshing; + +public static class GfxObjMesh +{ + /// + /// Walk a GfxObj's polygons and produce one + /// per referenced Surface. Polygons are triangulated as fans. + /// + public static IReadOnlyList Build(GfxObj gfxObj) + { + // Group output vertices and indices per surface index. + var perSurface = new Dictionary Vertices, List Indices, Dictionary<(int pos, int uv), uint> Dedupe)>(); + + foreach (var kvp in gfxObj.Polygons) + { + var poly = kvp.Value; + + if (poly.VertexIds.Count < 3) + continue; // degenerate + + int surfaceIdx = poly.PosSurface; + if (surfaceIdx < 0 || surfaceIdx >= gfxObj.Surfaces.Count) + continue; // out of range surface + + if (!perSurface.TryGetValue(surfaceIdx, out var bucket)) + { + bucket = (new List(), new List(), new Dictionary<(int, int), uint>()); + perSurface[surfaceIdx] = bucket; + } + + // Collect output vertex indices for this polygon. + var polyOut = new List(poly.VertexIds.Count); + bool skipPoly = false; + + for (int i = 0; i < poly.VertexIds.Count; i++) + { + int posIdx = poly.VertexIds[i]; + int uvIdx = i < poly.PosUVIndices.Count ? poly.PosUVIndices[i] : 0; + + if (!gfxObj.VertexArray.Vertices.TryGetValue((ushort)posIdx, out var sw)) + { + skipPoly = true; + break; + } + + var texcoord = uvIdx >= 0 && uvIdx < sw.UVs.Count + ? new Vector2(sw.UVs[uvIdx].U, sw.UVs[uvIdx].V) + : Vector2.Zero; + + var key = (posIdx, uvIdx); + if (!bucket.Dedupe.TryGetValue(key, out var outIdx)) + { + outIdx = (uint)bucket.Vertices.Count; + bucket.Vertices.Add(new Vertex(sw.Origin, sw.Normal, texcoord)); + bucket.Dedupe[key] = outIdx; + } + polyOut.Add(outIdx); + } + + if (skipPoly || polyOut.Count < 3) + continue; + + // Fan triangulation: (v0, v1, v2), (v0, v2, v3), ... + for (int i = 1; i < polyOut.Count - 1; i++) + { + bucket.Indices.Add(polyOut[0]); + bucket.Indices.Add(polyOut[i]); + bucket.Indices.Add(polyOut[i + 1]); + } + } + + // Emit one sub-mesh per surface. + var result = new List(perSurface.Count); + foreach (var kvp in perSurface) + { + var surfaceId = (uint)gfxObj.Surfaces[kvp.Key]; + result.Add(new GfxObjSubMesh( + SurfaceId: surfaceId, + Vertices: kvp.Value.Vertices.ToArray(), + Indices: kvp.Value.Indices.ToArray())); + } + return result; + } +} +``` + +Delete the placeholder: `rm src/AcDream.Core/Meshing/.gitkeep`. + +### Step 5: Run tests to verify they PASS + +```bash +dotnet test --filter "FullyQualifiedName~GfxObjMeshTests" +``` +Expected: `Passed: 6, Failed: 0`. + +Full suite: +```bash +dotnet test +``` +Expected: `Passed: 23, Failed: 0` (17 + 6). + +### Step 6: Commit + +```bash +git add src/AcDream.Core/Meshing tests/AcDream.Core.Tests/Meshing +git commit -m "feat(core): add GfxObjMesh.Build multi-surface mesh extractor" +``` + +--- + +## Task 3: `SurfaceDecoder.Decode` — BCn/palette texture decoder, TDD + +**Files:** +- Create: `src/AcDream.Core/Textures/DecodedTexture.cs` +- Create: `src/AcDream.Core/Textures/SurfaceDecoder.cs` +- Create: `tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs` +- Delete: `src/AcDream.Core/Textures/.gitkeep` + +**Background:** + +`Surface` (simplified from the DatReaderWriter generated file): +```csharp +public partial class Surface : DBObj { + public SurfaceType Type; + public QualifiedDataId OrigTextureId; // ptr to SurfaceTexture + // ... +} +``` + +`SurfaceTexture`: +```csharp +public partial class SurfaceTexture : DBObj { + public List> Textures = []; // typically one entry + // ... +} +``` + +`RenderSurface` holds the raw pixel bytes and format: +```csharp +public partial class RenderSurface : DBObj { + public uint BytesPerPixel; + public SurfacePixelFormat Format; + public int Width; + public int Height; + public byte[] SourceData; // raw bytes + public PaletteChain? DefaultPalette; // optional, for palette-indexed formats +} +``` + +`SurfacePixelFormat` enum values (from `DatReaderWriter/Generated/Enums/SurfacePixelFormat.generated.cs`) include: +- `PFID_INDEX16` (palette indexed, 16-bit index) +- `PFID_CUSTOM_RAW_JPEG` (jpeg-compressed) +- `PFID_A8R8G8B8` (32-bit BGRA) +- `PFID_DXT1`, `PFID_DXT3`, `PFID_DXT5` (BCn compressed) +- plus others we'll ignore for Phase 2 + +**Scope for Phase 2:** handle `PFID_A8R8G8B8`, `PFID_DXT1`, `PFID_DXT3`, `PFID_DXT5`, and the palette-indexed `PFID_INDEX16`. Everything else → return a 1×1 magenta fallback. + +**Don't confuse:** `Surface` is the thing `Polygon.PosSurface` indexes into via `GfxObj.Surfaces[]`. `SurfaceTexture` is one level deeper. `RenderSurface` is the actual pixel blob. + +**BCnEncoder.Net API (cheat sheet):** + +```csharp +using BCnEncoder.Decoder; +using BCnEncoder.Shared; + +var decoder = new BcDecoder(); +ColorRgba32[] pixels = decoder.DecodeRaw(bcBytes, width, height, CompressionFormat.Bc1); +// ColorRgba32 has .r, .g, .b, .a as byte fields +``` + +For `DXT1` use `CompressionFormat.Bc1`, `DXT3` → `Bc2`, `DXT5` → `Bc3`. + +### Step 1: Define output type + +```csharp +// src/AcDream.Core/Textures/DecodedTexture.cs +namespace AcDream.Core.Textures; + +public sealed record DecodedTexture(byte[] Rgba8, int Width, int Height) +{ + /// 1x1 magenta fallback for missing/unsupported textures. + public static readonly DecodedTexture Magenta = new( + Rgba8: [0xFF, 0x00, 0xFF, 0xFF], + Width: 1, + Height: 1); +} +``` + +### Step 2: Write failing tests + +```csharp +// tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs +using AcDream.Core.Textures; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; + +namespace AcDream.Core.Tests.Textures; + +public class SurfaceDecoderTests +{ + [Fact] + public void Decode_A8R8G8B8_ConvertsToRgba8() + { + // Source format is B, G, R, A in memory (little-endian ARGB). + // One 2x2 image: red, green, blue, white pixels. + var src = new byte[] + { + 0x00, 0x00, 0xFF, 0xFF, // red (B=0, G=0, R=255, A=255) + 0x00, 0xFF, 0x00, 0xFF, // green + 0xFF, 0x00, 0x00, 0xFF, // blue + 0xFF, 0xFF, 0xFF, 0xFF, // white + }; + var rs = new RenderSurface + { + Width = 2, + Height = 2, + Format = SurfacePixelFormat.PFID_A8R8G8B8, + SourceData = src, + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.Equal(2, decoded.Width); + Assert.Equal(2, decoded.Height); + Assert.Equal(16, decoded.Rgba8.Length); // 2*2*4 + // red pixel, in RGBA: 255, 0, 0, 255 + Assert.Equal(0xFF, decoded.Rgba8[0]); + Assert.Equal(0x00, decoded.Rgba8[1]); + Assert.Equal(0x00, decoded.Rgba8[2]); + Assert.Equal(0xFF, decoded.Rgba8[3]); + } + + [Fact] + public void Decode_UnsupportedFormat_ReturnsMagenta() + { + var rs = new RenderSurface + { + Width = 4, + Height = 4, + Format = SurfacePixelFormat.PFID_INDEX16, // not implemented path + SourceData = new byte[32], + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.Same(DecodedTexture.Magenta, decoded); + } + + [Fact] + public void Decode_NullSourceData_ReturnsMagenta() + { + var rs = new RenderSurface + { + Width = 4, + Height = 4, + Format = SurfacePixelFormat.PFID_A8R8G8B8, + SourceData = null!, + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.Same(DecodedTexture.Magenta, decoded); + } + + [Fact] + public void Decode_TruncatedA8R8G8B8_ReturnsMagenta() + { + // Buffer too small for width*height*4. + var rs = new RenderSurface + { + Width = 2, + Height = 2, + Format = SurfacePixelFormat.PFID_A8R8G8B8, + SourceData = new byte[8], // should be 16 + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.Same(DecodedTexture.Magenta, decoded); + } +} +``` + +**Notes on this test design:** + +- We test `DecodeRenderSurface` (the raw-pixel-blob entry point), not the full `Surface` → `SurfaceTexture` → `RenderSurface` chain. The chain requires a live `DatCollection` and is exercised manually later. +- We don't test DXT decode in unit tests because constructing a valid DXT1 block by hand is annoying. DXT decode will be exercised manually when we run the app against real dats. +- `PFID_INDEX16` is listed as "unsupported" in this task — palette decoding is deferred to a fix-up commit if we find it's needed after smoke testing. + +### Step 3: Run tests to fail + +```bash +dotnet test --filter "FullyQualifiedName~SurfaceDecoderTests" +``` +Expected: compile errors for `SurfaceDecoder`. RED. + +### Step 4: Implement + +```csharp +// src/AcDream.Core/Textures/SurfaceDecoder.cs +using BCnEncoder.Decoder; +using BCnEncoder.Shared; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; + +namespace AcDream.Core.Textures; + +public static class SurfaceDecoder +{ + private static readonly BcDecoder BcDecoder = new(); + + /// + /// Decode a RenderSurface's pixel bytes into RGBA8. Returns + /// for unsupported formats, null data, or corrupt sizing. + /// + public static DecodedTexture DecodeRenderSurface(RenderSurface rs) + { + if (rs.SourceData is null || rs.Width <= 0 || rs.Height <= 0) + return DecodedTexture.Magenta; + + try + { + return rs.Format switch + { + SurfacePixelFormat.PFID_A8R8G8B8 => DecodeA8R8G8B8(rs), + SurfacePixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1), + SurfacePixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2), + SurfacePixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3), + _ => DecodedTexture.Magenta, + }; + } + catch + { + return DecodedTexture.Magenta; + } + } + + private static DecodedTexture DecodeA8R8G8B8(RenderSurface rs) + { + int expected = rs.Width * rs.Height * 4; + if (rs.SourceData.Length < expected) + return DecodedTexture.Magenta; + + var rgba = new byte[expected]; + // Source layout per pixel: B, G, R, A → swap to R, G, B, A + for (int i = 0; i < rs.Width * rs.Height; i++) + { + int s = i * 4; + rgba[s + 0] = rs.SourceData[s + 2]; // R <- R + rgba[s + 1] = rs.SourceData[s + 1]; // G <- G + rgba[s + 2] = rs.SourceData[s + 0]; // B <- B + rgba[s + 3] = rs.SourceData[s + 3]; // A <- A + } + return new DecodedTexture(rgba, rs.Width, rs.Height); + } + + private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format) + { + var pixels = BcDecoder.DecodeRaw(rs.SourceData, rs.Width, rs.Height, format); + var rgba = new byte[rs.Width * rs.Height * 4]; + for (int i = 0; i < pixels.Length; i++) + { + int s = i * 4; + rgba[s + 0] = pixels[i].r; + rgba[s + 1] = pixels[i].g; + rgba[s + 2] = pixels[i].b; + rgba[s + 3] = pixels[i].a; + } + return new DecodedTexture(rgba, rs.Width, rs.Height); + } +} +``` + +Delete the placeholder: `rm src/AcDream.Core/Textures/.gitkeep`. + +### Step 5: Tests pass + +```bash +dotnet test --filter "FullyQualifiedName~SurfaceDecoderTests" +``` +Expected: `Passed: 4, Failed: 0`. + +Full suite: `Passed: 27, Failed: 0`. + +### Step 6: Commit + +```bash +git add src/AcDream.Core/Textures tests/AcDream.Core.Tests/Textures +git commit -m "feat(core): add SurfaceDecoder for A8R8G8B8 and BCn formats" +``` + +--- + +## Task 4: `LandblockLoader.Load` — parses Stabs + Buildings into WorldEntity, TDD + +**Files:** +- Create: `src/AcDream.Core/World/MeshRef.cs` +- Create: `src/AcDream.Core/World/WorldEntity.cs` +- Create: `src/AcDream.Core/World/LoadedLandblock.cs` +- Create: `src/AcDream.Core/World/LandblockLoader.cs` +- Create: `tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs` +- Delete: `src/AcDream.Core/World/.gitkeep` + +**Background on LandBlockInfo and ID resolution:** + +`LandBlockInfo` has two object lists: +```csharp +public partial class LandBlockInfo : DBObj { + public List Objects = []; // small decorations + public List Buildings = []; // buildings +} +``` + +`Stab`: +```csharp +public partial class Stab : IDatObjType { + public uint Id; // GfxObj or Setup id; top byte tells which + public Frame Frame; // .Origin + .Orientation +} +``` + +`BuildingInfo`: +```csharp +public partial class BuildingInfo : IDatObjType { + public uint ModelId; + public Frame Frame; + public List Portals = []; +} +``` + +`Frame`: +```csharp +public partial class Frame : IDatObjType { + public Vector3 Origin; + public Quaternion Orientation; +} +``` + +**AC dat id type resolution (from ACViewer's `PhysicsObj.InitPartArrayObject`):** +- `(id & 0xFF000000) == 0x01000000` → GfxObj +- `(id & 0xFF000000) == 0x02000000` → Setup +- anything else → unsupported in Phase 2, skip + +For Phase 2 **Task 4 only**, we're not actually walking the GfxObj/Setup to produce `MeshRef` entries yet — that's Task 5 (`SetupMesh.Flatten`) and the glue that comes with it. **Task 4's `LandblockLoader.Load` just produces `WorldEntity` with an empty `MeshRefs` list** and leaves the mesh-flattening for later. The test asserts entity count, positions, and source ids but not meshes. + +This is deliberate: it keeps Task 4 pure and testable without bringing the DatCollection into the test. + +### Step 1: Define types + +```csharp +// src/AcDream.Core/World/MeshRef.cs +using System.Numerics; + +namespace AcDream.Core.World; + +public readonly record struct MeshRef(uint GfxObjId, Matrix4x4 PartTransform); +``` + +```csharp +// src/AcDream.Core/World/WorldEntity.cs +using System.Numerics; + +namespace AcDream.Core.World; + +public sealed class WorldEntity +{ + public required uint Id { get; init; } + public required uint SourceGfxObjOrSetupId { get; init; } + public required Vector3 Position { get; init; } + public required Quaternion Rotation { get; init; } + public required IReadOnlyList MeshRefs { get; init; } +} +``` + +```csharp +// src/AcDream.Core/World/LoadedLandblock.cs +using DatReaderWriter.DBObjs; + +namespace AcDream.Core.World; + +public sealed record LoadedLandblock( + uint LandblockId, + LandBlock Heightmap, + IReadOnlyList Entities); +``` + +### Step 2: Write failing tests + +```csharp +// tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs +using System.Numerics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.World; + +public class LandblockLoaderTests +{ + private static LandBlock BuildFlatLandBlock() + { + var block = new LandBlock + { + HasObjects = true, + Terrain = new TerrainInfo[81], + Height = new byte[81], + }; + for (int i = 0; i < 81; i++) + { + block.Terrain[i] = (ushort)0; + block.Height[i] = 0; + } + return block; + } + + [Fact] + public void BuildEntitiesFromInfo_StabsAndBuildings_AreMappedToEntities() + { + var info = new LandBlockInfo + { + Objects = + { + new Stab + { + Id = 0x01000042u, // GfxObj id + Frame = new Frame + { + Origin = new Vector3(10, 20, 5), + Orientation = Quaternion.Identity, + }, + }, + new Stab + { + Id = 0x02000099u, // Setup id + Frame = new Frame + { + Origin = new Vector3(30, 40, 10), + Orientation = Quaternion.Identity, + }, + }, + }, + Buildings = + { + new BuildingInfo + { + ModelId = 0x020000AAu, // Setup for a building + Frame = new Frame + { + Origin = new Vector3(50, 60, 0), + Orientation = Quaternion.Identity, + }, + }, + }, + }; + + var entities = LandblockLoader.BuildEntitiesFromInfo(info); + + Assert.Equal(3, entities.Count); + Assert.Contains(entities, e => e.SourceGfxObjOrSetupId == 0x01000042u && e.Position == new Vector3(10, 20, 5)); + Assert.Contains(entities, e => e.SourceGfxObjOrSetupId == 0x02000099u && e.Position == new Vector3(30, 40, 10)); + Assert.Contains(entities, e => e.SourceGfxObjOrSetupId == 0x020000AAu && e.Position == new Vector3(50, 60, 0)); + } + + [Fact] + public void BuildEntitiesFromInfo_AssignsMonotonicIds() + { + var info = new LandBlockInfo + { + Objects = + { + new Stab { Id = 0x01000001u, Frame = new Frame() }, + new Stab { Id = 0x01000002u, Frame = new Frame() }, + new Stab { Id = 0x01000003u, Frame = new Frame() }, + }, + }; + + var entities = LandblockLoader.BuildEntitiesFromInfo(info); + + var ids = entities.Select(e => e.Id).OrderBy(i => i).ToArray(); + Assert.Equal(3, ids.Distinct().Count()); // all unique + } + + [Fact] + public void BuildEntitiesFromInfo_UnsupportedIdType_IsSkipped() + { + // 0x03xxxxxx is neither GfxObj (0x01) nor Setup (0x02). + var info = new LandBlockInfo + { + Objects = + { + new Stab { Id = 0x01000001u, Frame = new Frame() }, + new Stab { Id = 0x03000002u, Frame = new Frame() }, // skipped + new Stab { Id = 0x02000003u, Frame = new Frame() }, + }, + }; + + var entities = LandblockLoader.BuildEntitiesFromInfo(info); + + Assert.Equal(2, entities.Count); + Assert.DoesNotContain(entities, e => e.SourceGfxObjOrSetupId == 0x03000002u); + } + + [Fact] + public void BuildEntitiesFromInfo_Empty_ReturnsEmpty() + { + var entities = LandblockLoader.BuildEntitiesFromInfo(new LandBlockInfo()); + Assert.Empty(entities); + } +} +``` + +### Step 3: Run tests to fail + +```bash +dotnet test --filter "FullyQualifiedName~LandblockLoaderTests" +``` +Expected: RED. + +### Step 4: Implement + +```csharp +// src/AcDream.Core/World/LandblockLoader.cs +using DatReaderWriter; +using DatReaderWriter.DBObjs; + +namespace AcDream.Core.World; + +public static class LandblockLoader +{ + private const uint GfxObjMask = 0x01000000u; + private const uint SetupMask = 0x02000000u; + private const uint TypeMask = 0xFF000000u; + + /// + /// Load a single landblock (heightmap + static objects) from the dats. + /// + /// Null if the landblock is missing from the cell dat. + public static LoadedLandblock? Load(DatCollection dats, uint landblockId) + { + var block = dats.Get(landblockId); + if (block is null) + return null; + + var info = dats.Get((landblockId & 0xFFFF0000u) | 0xFFFEu); + var entities = info is null + ? Array.Empty() + : BuildEntitiesFromInfo(info); + + return new LoadedLandblock(landblockId, block, entities); + } + + /// + /// Pure mapping from a parsed LandBlockInfo to a list of WorldEntity. + /// Each Stab and BuildingInfo becomes one entity. Unsupported id types + /// (neither GfxObj 0x01xxxxxx nor Setup 0x02xxxxxx) are silently skipped. + /// MeshRefs is left empty at this stage — Task 5 populates it. + /// + public static IReadOnlyList BuildEntitiesFromInfo(LandBlockInfo info) + { + var result = new List(info.Objects.Count + info.Buildings.Count); + uint nextId = 1; + + foreach (var stab in info.Objects) + { + if (!IsSupported(stab.Id)) + continue; + result.Add(new WorldEntity + { + Id = nextId++, + SourceGfxObjOrSetupId = stab.Id, + Position = stab.Frame.Origin, + Rotation = stab.Frame.Orientation, + MeshRefs = Array.Empty(), + }); + } + + foreach (var building in info.Buildings) + { + if (!IsSupported(building.ModelId)) + continue; + result.Add(new WorldEntity + { + Id = nextId++, + SourceGfxObjOrSetupId = building.ModelId, + Position = building.Frame.Origin, + Rotation = building.Frame.Orientation, + MeshRefs = Array.Empty(), + }); + } + + return result; + } + + private static bool IsSupported(uint id) + { + var type = id & TypeMask; + return type == GfxObjMask || type == SetupMask; + } +} +``` + +Delete the placeholder: `rm src/AcDream.Core/World/.gitkeep`. + +### Step 5: Tests pass + +```bash +dotnet test --filter "FullyQualifiedName~LandblockLoaderTests" +``` +Expected: `Passed: 4`. + +Full suite: 31 passing. + +### Step 6: Commit + +```bash +git add src/AcDream.Core/World tests/AcDream.Core.Tests/World +git commit -m "feat(core): add LandblockLoader with Stab+Building → WorldEntity mapping" +``` + +--- + +## Task 5: `SetupMesh.Flatten` — walk Setup part hierarchy, TDD + +**Files:** +- Create: `src/AcDream.Core/Meshing/SetupMesh.cs` +- Create: `tests/AcDream.Core.Tests/Meshing/SetupMeshTests.cs` + +**Background:** + +A `Setup` represents a multi-part object. Its `Parts: List>` lists the constituent GfxObjs. `ParentIndex: List` gives the parent part index for each part (forming a tree). `DefaultScale: List` gives the per-part scale. `PlacementFrames: Dictionary` has default part positions keyed by the `Placement` enum. + +**For Phase 2 we take the simplest viable path:** each part gets a transform = `Translation(Frame.Origin) × Rotation(Frame.Orientation) × Scale(DefaultScale[i])`, taken from the `Placement.Default` entry. We do NOT walk the parent chain yet — if a Setup has hierarchical parts, the rendered result may be wrong. This is acceptable for Phase 2 because most buildings are single-part Setups. Complex multi-part hierarchy handling is Phase 3. + +(If, during smoke testing, we discover that Holtburg's buildings look broken because of unhandled hierarchy, we revisit this as a fix-up commit.) + +**The algorithm for `SetupMesh.Flatten(Setup setup)`:** + +1. Get the default `AnimationFrame` from `setup.PlacementFrames[Placement.Default]`. If missing, use identity. +2. For each part `i` in `0..Parts.Count-1`: + - `gfxObjId = (uint)setup.Parts[i]` + - `frame = defaultAnim.Frames[i]` if in range, else identity + - `scale = setup.DefaultScale[i]` if in range, else `Vector3.One` + - `transform = Scale(scale) * Rotation(frame.Orientation) * Translation(frame.Origin)` (column-major multiplication — `Matrix4x4` in System.Numerics is row-major; use `CreateScale` / `CreateFromQuaternion` / `CreateTranslation` and chain with `*`) +3. Yield `(gfxObjId, transform)` tuples. + +**Look at `AnimationFrame`:** + + +`AnimationFrame` is `public partial class AnimationFrame : IDatObjType { public List Frames = []; }` — a list of per-part `Frame` objects. Each part's frame is at index `i` matching `Setup.Parts[i]`. + + +### Step 1: Write failing tests + +```csharp +// tests/AcDream.Core.Tests/Meshing/SetupMeshTests.cs +using System.Numerics; +using AcDream.Core.Meshing; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.Meshing; + +public class SetupMeshTests +{ + [Fact] + public void Flatten_SinglePartSetup_YieldsOneMeshRef() + { + var setup = new Setup + { + Parts = { 0x01000100u }, + DefaultScale = { Vector3.One }, + PlacementFrames = + { + [Placement.Default] = new AnimationFrame + { + Frames = + { + new Frame + { + Origin = new Vector3(0, 0, 0), + Orientation = Quaternion.Identity, + }, + }, + }, + }, + }; + + var refs = SetupMesh.Flatten(setup); + + var single = Assert.Single(refs); + Assert.Equal(0x01000100u, single.GfxObjId); + // Identity-ish transform + Assert.Equal(Matrix4x4.Identity, single.PartTransform); + } + + [Fact] + public void Flatten_TwoPartSetup_YieldsTwoMeshRefs() + { + var setup = new Setup + { + Parts = { 0x01000100u, 0x01000200u }, + DefaultScale = { Vector3.One, Vector3.One }, + PlacementFrames = + { + [Placement.Default] = new AnimationFrame + { + Frames = + { + new Frame { Origin = new(0, 0, 0), Orientation = Quaternion.Identity }, + new Frame { Origin = new(10, 0, 0), Orientation = Quaternion.Identity }, + }, + }, + }, + }; + + var refs = SetupMesh.Flatten(setup); + + Assert.Equal(2, refs.Count); + Assert.Equal(0x01000100u, refs[0].GfxObjId); + Assert.Equal(0x01000200u, refs[1].GfxObjId); + // Second part is translated by 10 on X. + Assert.Equal(10f, refs[1].PartTransform.Translation.X); + } + + [Fact] + public void Flatten_PartScale_IsAppliedToTransform() + { + var setup = new Setup + { + Parts = { 0x01000100u }, + DefaultScale = { new Vector3(2, 3, 4) }, + PlacementFrames = + { + [Placement.Default] = new AnimationFrame + { + Frames = { new Frame { Orientation = Quaternion.Identity } }, + }, + }, + }; + + var refs = SetupMesh.Flatten(setup); + + // The transform's M11 = 2 (scale X), M22 = 3, M33 = 4 + Assert.Equal(2f, refs[0].PartTransform.M11); + Assert.Equal(3f, refs[0].PartTransform.M22); + Assert.Equal(4f, refs[0].PartTransform.M33); + } + + [Fact] + public void Flatten_MissingPlacementFrame_UsesIdentity() + { + var setup = new Setup + { + Parts = { 0x01000100u }, + DefaultScale = { Vector3.One }, + // PlacementFrames deliberately empty + }; + + var refs = SetupMesh.Flatten(setup); + + Assert.Single(refs); + Assert.Equal(Matrix4x4.Identity, refs[0].PartTransform); + } +} +``` + +### Step 2: Run tests — RED + +```bash +dotnet test --filter "FullyQualifiedName~SetupMeshTests" +``` + +### Step 3: Implement + +```csharp +// src/AcDream.Core/Meshing/SetupMesh.cs +using System.Numerics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Core.Meshing; + +public static class SetupMesh +{ + /// + /// Flatten a Setup into a list of (GfxObjId, PartTransform) refs. + /// Uses the default placement frame and DefaultScale per part. + /// Does NOT walk ParentIndex — each part's transform is local to the setup root. + /// This is simplification for Phase 2; complex hierarchical rigs are Phase 3. + /// + public static IReadOnlyList Flatten(Setup setup) + { + AnimationFrame? defaultAnim = null; + if (setup.PlacementFrames.TryGetValue(Placement.Default, out var af)) + defaultAnim = af; + + var result = new List(setup.Parts.Count); + for (int i = 0; i < setup.Parts.Count; i++) + { + uint gfxObjId = (uint)setup.Parts[i]; + + Frame frame; + if (defaultAnim is not null && i < defaultAnim.Frames.Count) + frame = defaultAnim.Frames[i]; + else + frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; + + Vector3 scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : Vector3.One; + + var transform = + Matrix4x4.CreateScale(scale) * + Matrix4x4.CreateFromQuaternion(frame.Orientation) * + Matrix4x4.CreateTranslation(frame.Origin); + + result.Add(new MeshRef(gfxObjId, transform)); + } + return result; + } +} +``` + +### Step 4: Tests pass + +```bash +dotnet test --filter "FullyQualifiedName~SetupMeshTests" +``` +Expected: `Passed: 4`. + +Full suite: 35 passing. + +### Step 5: Commit + +```bash +git add src/AcDream.Core/Meshing/SetupMesh.cs tests/AcDream.Core.Tests/Meshing/SetupMeshTests.cs +git commit -m "feat(core): add SetupMesh.Flatten for single-level part hierarchy" +``` + +--- + +## Task 6: `WorldView` — 3×3 neighbor grid, TDD + +**Files:** +- Create: `src/AcDream.Core/World/WorldView.cs` +- Create: `tests/AcDream.Core.Tests/World/WorldViewTests.cs` + +**Background on landblock IDs:** + +AC landblock IDs are 32-bit values where the high 16 bits are the landblock coordinate: `0xXXYY0000`. The low 16 bits distinguish the file type within the landblock (`0xFFFF` for the heightmap `LandBlock`, `0xFFFE` for the `LandBlockInfo`). So Holtburg is `0xA9B4FFFF` for the heightmap; its coord is `0xA9` in X and `0xB4` in Y. + +**Neighbor computation:** for center `(cx, cy)`, the 3×3 neighbors are `(cx-1..cx+1, cy-1..cy+1)`. Clamp to `[0, 0xFF]` — if a neighbor would underflow or overflow, skip it. Rebuild the neighbor id as `((x << 24) | (y << 16)) | 0xFFFFu`. + +**For this task**, we write `WorldView` as a pure struct/class that computes neighbor ids. We do NOT load them — loading uses `LandblockLoader` which needs a `DatCollection`, and tests don't want to instantiate that. The `WorldView.LoadFromDats` method that walks the 3×3 grid is wired up in Task 8 (`GameWindow` integration) without its own unit test. + +### Step 1: Write failing tests + +```csharp +// tests/AcDream.Core.Tests/World/WorldViewTests.cs +using AcDream.Core.World; + +namespace AcDream.Core.Tests.World; + +public class WorldViewTests +{ + [Fact] + public void NeighborIds_Center_Returns9Ids() + { + var ids = WorldView.NeighborLandblockIds(0xA9B4FFFFu).ToList(); + + Assert.Equal(9, ids.Count); + Assert.Contains(0xA9B4FFFFu, ids); // center + Assert.Contains(0xA8B3FFFFu, ids); // NW + Assert.Contains(0xAAB5FFFFu, ids); // SE + } + + [Fact] + public void NeighborIds_LowerEdge_ClampsUnderflow() + { + // Landblock 0x0000FFFF — no west or south neighbors. + var ids = WorldView.NeighborLandblockIds(0x0000FFFFu).ToList(); + + // 4 neighbors should exist: center + E + N + NE + Assert.Equal(4, ids.Count); + Assert.Contains(0x0000FFFFu, ids); + Assert.Contains(0x0100FFFFu, ids); + Assert.Contains(0x0001FFFFu, ids); + Assert.Contains(0x0101FFFFu, ids); + } + + [Fact] + public void NeighborIds_UpperEdge_ClampsOverflow() + { + // Landblock 0xFFFFFFFF — no east or north neighbors. + var ids = WorldView.NeighborLandblockIds(0xFFFFFFFFu).ToList(); + + // 4 neighbors: center + W + S + SW + Assert.Equal(4, ids.Count); + Assert.Contains(0xFFFFFFFFu, ids); + Assert.Contains(0xFEFFFFFFu, ids); + Assert.Contains(0xFFFEFFFFu, ids); + Assert.Contains(0xFEFEFFFFu, ids); + } +} +``` + +### Step 2: RED + +```bash +dotnet test --filter "FullyQualifiedName~WorldViewTests" +``` + +### Step 3: Implement + +```csharp +// src/AcDream.Core/World/WorldView.cs +using DatReaderWriter; + +namespace AcDream.Core.World; + +public sealed class WorldView +{ + public uint CenterLandblockId { get; } + public IReadOnlyList Landblocks { get; } + public IEnumerable AllEntities => Landblocks.SelectMany(lb => lb.Entities); + + private WorldView(uint centerLandblockId, IReadOnlyList landblocks) + { + CenterLandblockId = centerLandblockId; + Landblocks = landblocks; + } + + /// + /// Load the 3x3 grid of landblocks around . + /// Missing neighbors (edges of the world or absent from the cell dat) are silently skipped. + /// + public static WorldView Load(DatCollection dats, uint centerLandblockId) + { + var loaded = new List(); + foreach (var id in NeighborLandblockIds(centerLandblockId)) + { + var lb = LandblockLoader.Load(dats, id); + if (lb is not null) + loaded.Add(lb); + } + return new WorldView(centerLandblockId, loaded); + } + + /// + /// Enumerate the 3x3 neighbor landblock ids around a center. Clamps at the world edges + /// (skipping neighbors that would underflow or overflow the 8-bit coordinate range). + /// + public static IEnumerable NeighborLandblockIds(uint centerLandblockId) + { + int cx = (int)((centerLandblockId >> 24) & 0xFFu); + int cy = (int)((centerLandblockId >> 16) & 0xFFu); + + for (int dy = -1; dy <= 1; dy++) + { + for (int dx = -1; dx <= 1; dx++) + { + int nx = cx + dx; + int ny = cy + dy; + if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) + continue; + yield return (uint)((nx << 24) | (ny << 16) | 0xFFFFu); + } + } + } +} +``` + +### Step 4: Tests pass + +Full suite: 38 passing. + +### Step 5: Commit + +```bash +git add src/AcDream.Core/World/WorldView.cs tests/AcDream.Core.Tests/World/WorldViewTests.cs +git commit -m "feat(core): add WorldView with 3x3 neighbor landblock computation" +``` + +--- + +## Task 7: `TextureCache` — App-side GL handle cache, no tests (GL-coupled) + +**Files:** +- Create: `src/AcDream.App/Rendering/TextureCache.cs` + +No unit tests — this owns GL state and the whole point is interacting with it. It will be exercised in Task 8's smoke. + +**Dependencies:** +- Takes a `GL` instance and a `DatCollection` +- Calls `SurfaceDecoder.DecodeRenderSurface` from `AcDream.Core.Textures` +- Uploads RGBA8 bytes as GL textures + +**Note on the Surface→RenderSurface chain:** for Task 7 we need to actually walk the chain from a `Surface` id to its `RenderSurface`. That requires `Surface.OrigTextureId → SurfaceTexture.Textures[0] → RenderSurface`. If any link is missing, upload a 1×1 magenta texture. + +### Step 1: Implement + +```csharp +// src/AcDream.App/Rendering/TextureCache.cs +using AcDream.Core.Textures; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +public sealed unsafe class TextureCache : IDisposable +{ + private readonly GL _gl; + private readonly DatCollection _dats; + private readonly Dictionary _handlesBySurfaceId = new(); + private uint _magentaHandle; + + public TextureCache(GL gl, DatCollection dats) + { + _gl = gl; + _dats = dats; + } + + /// + /// Get or upload the GL texture handle for a Surface id. Returns a + /// 1x1 magenta fallback if the Surface or its RenderSurface chain is + /// missing or uses an unsupported format. + /// + public uint GetOrUpload(uint surfaceId) + { + if (_handlesBySurfaceId.TryGetValue(surfaceId, out var h)) + return h; + + var decoded = DecodeFromDats(surfaceId); + h = UploadRgba8(decoded); + _handlesBySurfaceId[surfaceId] = h; + return h; + } + + private DecodedTexture DecodeFromDats(uint surfaceId) + { + var surface = _dats.Get(surfaceId); + if (surface is null) + return DecodedTexture.Magenta; + + var surfaceTexture = _dats.Get((uint)surface.OrigTextureId); + if (surfaceTexture is null || surfaceTexture.Textures.Count == 0) + return DecodedTexture.Magenta; + + var rs = _dats.Get((uint)surfaceTexture.Textures[0]); + if (rs is null) + return DecodedTexture.Magenta; + + return SurfaceDecoder.DecodeRenderSurface(rs); + } + + private uint UploadRgba8(DecodedTexture decoded) + { + uint tex = _gl.GenTexture(); + _gl.BindTexture(TextureTarget.Texture2D, tex); + + fixed (byte* p = decoded.Rgba8) + _gl.TexImage2D( + TextureTarget.Texture2D, + 0, + InternalFormat.Rgba8, + (uint)decoded.Width, + (uint)decoded.Height, + 0, + PixelFormat.Rgba, + PixelType.UnsignedByte, + p); + + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); + + _gl.BindTexture(TextureTarget.Texture2D, 0); + return tex; + } + + public void Dispose() + { + foreach (var h in _handlesBySurfaceId.Values) + _gl.DeleteTexture(h); + _handlesBySurfaceId.Clear(); + if (_magentaHandle != 0) + { + _gl.DeleteTexture(_magentaHandle); + _magentaHandle = 0; + } + } +} +``` + +### Step 2: Build to confirm compilation + +```bash +dotnet build +``` +Expected: 0 warnings, 0 errors. + +### Step 3: Commit + +```bash +git add src/AcDream.App/Rendering/TextureCache.cs +git commit -m "feat(app): add TextureCache for Surface→GL texture handle caching" +``` + +--- + +## Task 8: `StaticMeshRenderer` + new mesh shader + wire into `GameWindow` + +**Files:** +- Create: `src/AcDream.App/Rendering/Shaders/mesh.vert` +- Create: `src/AcDream.App/Rendering/Shaders/mesh.frag` +- Create: `src/AcDream.App/Rendering/StaticMeshRenderer.cs` +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (wire up loading + drawing) + +**Goal of this task:** when we run the app, buildings should be visible on top of Holtburg's terrain as untextured magenta blobs (because the TextureCache falls back to magenta for any texture issue until the shader actually samples). They'll look weird but *present*. + +### Step 1: Shaders + +```glsl +// src/AcDream.App/Rendering/Shaders/mesh.vert +#version 430 core +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTex; + +uniform mat4 uModel; +uniform mat4 uView; +uniform mat4 uProjection; + +out vec2 vTex; + +void main() { + vTex = aTex; + gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0); +} +``` + +```glsl +// src/AcDream.App/Rendering/Shaders/mesh.frag +#version 430 core +in vec2 vTex; +out vec4 fragColor; + +uniform sampler2D uDiffuse; + +void main() { + fragColor = texture(uDiffuse, vTex); +} +``` + +The `AcDream.App.csproj` already has a `` item group from Phase 1, so the new shader files will copy to the output automatically. No csproj changes. + +### Step 2: `StaticMeshRenderer` + +```csharp +// src/AcDream.App/Rendering/StaticMeshRenderer.cs +using System.Numerics; +using AcDream.Core.Meshing; +using AcDream.Core.Terrain; +using AcDream.Core.World; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +public sealed unsafe class StaticMeshRenderer : IDisposable +{ + private readonly GL _gl; + private readonly Shader _shader; + private readonly TextureCache _textures; + + // One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes. + private readonly Dictionary> _gpuByGfxObj = new(); + + public StaticMeshRenderer(GL gl, Shader shader, TextureCache textures) + { + _gl = gl; + _shader = shader; + _textures = textures; + } + + public void EnsureUploaded(uint gfxObjId, IReadOnlyList subMeshes) + { + if (_gpuByGfxObj.ContainsKey(gfxObjId)) + return; + + var list = new List(subMeshes.Count); + foreach (var sm in subMeshes) + list.Add(UploadSubMesh(sm)); + _gpuByGfxObj[gfxObjId] = list; + } + + private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm) + { + uint vao = _gl.GenVertexArray(); + _gl.BindVertexArray(vao); + + uint vbo = _gl.GenBuffer(); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo); + fixed (void* p = sm.Vertices) + _gl.BufferData(BufferTargetARB.ArrayBuffer, + (nuint)(sm.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw); + + uint ebo = _gl.GenBuffer(); + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo); + fixed (void* p = sm.Indices) + _gl.BufferData(BufferTargetARB.ElementArrayBuffer, + (nuint)(sm.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw); + + uint stride = (uint)sizeof(Vertex); + _gl.EnableVertexAttribArray(0); + _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0); + _gl.EnableVertexAttribArray(1); + _gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float))); + _gl.EnableVertexAttribArray(2); + _gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float))); + + _gl.BindVertexArray(0); + + return new SubMeshGpu + { + Vao = vao, + Vbo = vbo, + Ebo = ebo, + IndexCount = sm.Indices.Length, + SurfaceId = sm.SurfaceId, + }; + } + + public void Draw(OrbitCamera camera, IEnumerable entities) + { + _shader.Use(); + _shader.SetMatrix4("uView", camera.View); + _shader.SetMatrix4("uProjection", camera.Projection); + + foreach (var entity in entities) + { + if (entity.MeshRefs.Count == 0) + continue; + + foreach (var meshRef in entity.MeshRefs) + { + if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes)) + continue; + + // model = entity root transform * per-part transform + var entityRoot = + Matrix4x4.CreateFromQuaternion(entity.Rotation) * + Matrix4x4.CreateTranslation(entity.Position); + var model = meshRef.PartTransform * entityRoot; + + _shader.SetMatrix4("uModel", model); + + foreach (var sub in subMeshes) + { + uint tex = _textures.GetOrUpload(sub.SurfaceId); + _gl.ActiveTexture(TextureUnit.Texture0); + _gl.BindTexture(TextureTarget.Texture2D, tex); + + _gl.BindVertexArray(sub.Vao); + _gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0); + } + } + } + _gl.BindVertexArray(0); + } + + public void Dispose() + { + foreach (var subs in _gpuByGfxObj.Values) + { + foreach (var sub in subs) + { + _gl.DeleteBuffer(sub.Vbo); + _gl.DeleteBuffer(sub.Ebo); + _gl.DeleteVertexArray(sub.Vao); + } + } + _gpuByGfxObj.Clear(); + } + + private sealed class SubMeshGpu + { + public uint Vao; + public uint Vbo; + public uint Ebo; + public int IndexCount; + public uint SurfaceId; + } +} +``` + +### Step 3: Wire into `GameWindow` + +Edit `src/AcDream.App/Rendering/GameWindow.cs`. Add fields, wire up loading in `OnLoad`, call `Draw` in `OnRender`, dispose in `OnClosing`. + +**Fields to add** (near the existing `_terrain`, `_shader`, `_camera` fields): +```csharp +private StaticMeshRenderer? _staticMesh; +private Shader? _meshShader; +private TextureCache? _textureCache; +``` + +**In `OnLoad`, after the existing terrain shader + camera setup but BEFORE the dat open:** add the mesh shader load: + +```csharp +_meshShader = new Shader(_gl, + Path.Combine(shadersDir, "mesh.vert"), + Path.Combine(shadersDir, "mesh.frag")); +``` + +**After `_dats = new DatCollection(...)` and after the existing landblock-finding block:** add the static mesh loading pipeline: + +```csharp +_textureCache = new TextureCache(_gl, _dats); +_staticMesh = new StaticMeshRenderer(_gl, _meshShader, _textureCache); + +// Load LandBlockInfo for Holtburg, hydrate entities. +var info = _dats.Get((landblockId & 0xFFFF0000u) | 0xFFFEu); +var entities = info is not null + ? AcDream.Core.World.LandblockLoader.BuildEntitiesFromInfo(info) + : Array.Empty(); + +// Populate MeshRefs for each entity by resolving its source id to GfxObj or Setup +// and extracting sub-meshes. Store back onto the entity. Since WorldEntity is +// `required init`, we rebuild the entity here. +var hydratedEntities = new List(entities.Count); +foreach (var e in entities) +{ + var meshRefs = new List(); + + if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u) + { + // GfxObj: one mesh ref with identity transform. + var gfx = _dats.Get(e.SourceGfxObjOrSetupId); + if (gfx is not null) + { + var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx); + _staticMesh.EnsureUploaded(e.SourceGfxObjOrSetupId, subMeshes); + meshRefs.Add(new AcDream.Core.World.MeshRef(e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity)); + } + } + else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) + { + // Setup: flatten into parts, upload each part's GfxObj. + var setup = _dats.Get(e.SourceGfxObjOrSetupId); + if (setup is not null) + { + var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); + foreach (var mr in flat) + { + var gfx = _dats.Get(mr.GfxObjId); + if (gfx is null) continue; + var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx); + _staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes); + meshRefs.Add(mr); + } + } + } + + if (meshRefs.Count > 0) + { + hydratedEntities.Add(new AcDream.Core.World.WorldEntity + { + Id = e.Id, + SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId, + Position = e.Position, + Rotation = e.Rotation, + MeshRefs = meshRefs, + }); + } +} + +_entities = hydratedEntities; +Console.WriteLine($"hydrated {_entities.Count} entities on landblock 0x{landblockId:X8}"); +``` + +**Add a field:** `private IReadOnlyList _entities = Array.Empty();` + +**In `OnRender`, after the terrain `.Draw` call:** +```csharp +_staticMesh?.Draw(_camera!, _entities); +``` + +**In `OnClosing`, before disposing `_dats`:** +```csharp +_staticMesh?.Dispose(); +_textureCache?.Dispose(); +_meshShader?.Dispose(); +``` + +### Step 4: Build + +```bash +dotnet build +``` +Expected: 0 warnings, 0 errors. If you hit nullable-generic issues, remember Phase 1's workaround was to use `Get` not `TryGet`. + +### Step 5: Manual smoke against real dats + +```bash +dotnet run --project src/AcDream.App -- "references/Asheron's Call" +``` + +**Expected visible result:** the window opens, Holtburg terrain renders as before, and there are now **magenta blobs** at various positions on the terrain — those are the buildings and static objects, all sampling the fallback texture because the actual Surface chain hasn't been resolved (it's Task 7's `TextureCache` code path via `SurfaceDecoder`, so this should actually work… but if it produces magenta-only, that's the fallback because the format isn't handled or the surface chain failed). + +**Expected console:** `hydrated entities on landblock 0xA9B4FFFF` where N is hopefully > 0 but could be very low if Holtburg has few static objects or if most are unsupported. + +**Don't panic if:** +- The buildings are mostly magenta — that just means textures aren't resolving yet, and Task 10 handles that. +- The building *shapes* look wrong — this is the risk flagged in the design. We address it with wireframe debug later if needed. +- Entity count is low (say 10-20) — Holtburg center is sparse compared to outdoor areas. + +**DO stop and debug if:** +- Exceptions are thrown during load +- Build fails +- Nothing renders at all (terrain disappears) +- Entity count is 0 + +### Step 6: Commit + +```bash +git add src/AcDream.App/Rendering/StaticMeshRenderer.cs src/AcDream.App/Rendering/Shaders/mesh.vert src/AcDream.App/Rendering/Shaders/mesh.frag src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(app): render static meshes from Holtburg LandBlockInfo" +``` + +--- + +## Task 9: Debug — verify buildings render with non-magenta textures + +**Files:** +- Possibly fix: `src/AcDream.App/Rendering/TextureCache.cs`, `src/AcDream.Core/Textures/SurfaceDecoder.cs`, or related. + +**This task exists because texturing is the part most likely to go wrong on first attempt.** It's a placeholder for whatever diagnostic + fix work the Task 8 smoke reveals. Possible outcomes and responses: + +| Symptom | Likely cause | Fix | +|---|---|---| +| All magenta | Surface chain failing at some link | Add logging to `TextureCache.DecodeFromDats`, trace which `_dats.Get<>` returns null | +| All magenta + a format we support reaches the decoder | Byte-order bug in `DecodeA8R8G8B8` (AC might be ARGB not BGRA) | Fix the channel swap | +| Some magenta, some textures | Many surfaces use `PFID_INDEX16` | Implement `PFID_INDEX16` decoder using `Palette` + `PaletteChain` lookup | +| Textures upload but buildings look grey / black | UV coordinates are 0,0 for all vertices — possibly a UV-index bug in `GfxObjMesh.Build` | Check `PosUVIndices` unpacking | +| Crash on upload | Width/height is 0 or bogus | Add a size check in `TextureCache.UploadRgba8` | + +**Process:** run the app, observe, add one-line diagnostic `Console.WriteLine` logging at suspicious spots, re-run, narrow, fix, remove logging, commit. + +**Acceptance for this task:** at least SOME buildings on Holtburg render with non-magenta textures. If everything is still magenta but we've clearly verified the decoder path and confirmed the format is just an unsupported one (e.g., `PFID_INDEX16`), that's acceptable — we commit a placeholder fix and move on. + +### Step 1: Run smoke, observe + +```bash +dotnet run --project src/AcDream.App -- "references/Asheron's Call" +``` + +### Step 2: Narrow with targeted `Console.WriteLine` + +(Add logging, re-run, remove logging before commit.) + +### Step 3: Commit whatever fix resolves it + +```bash +git add +git commit -m "fix(textures): " +``` + +If no fix was needed (first attempt worked end-to-end, unlikely but possible), this task becomes a no-op and we just continue to Task 10. + +--- + +## Task 10 (stopping point): Visually verify Holtburg renders with buildings + textures + +**Files:** none (verification only) + +**Goal:** the user opens the app, sees Holtburg terrain with recognizable buildings on top, some visually textured. Phase 2a is done at this point. + +**Don't commit anything here** — this is a manual acceptance checkpoint. + +### Step 1: Run + +```bash +dotnet run --project src/AcDream.App -- "references/Asheron's Call" +``` + +### Step 2: Verify + +- [ ] Window opens, terrain renders as before +- [ ] Buildings visible at expected positions (not in mid-air, not underground) +- [ ] At least some buildings have recognizable textures (not all magenta) +- [ ] Orbit camera still works (drag rotates, scroll zooms) +- [ ] Escape still closes cleanly +- [ ] Console shows `hydrated entities on landblock 0xA9B4FFFF` with N > 0 +- [ ] Smoke plugin lifecycle logs still appear +- [ ] All 38+ tests still pass + +### Step 3: Summarize for next session + +Write notes on: +- Final entity count on Holtburg +- Any visual anomalies to investigate in Phase 2b (flipped textures, missing buildings, wrong colors) +- Any surfaces that we saw fall back to magenta (for palette-decoder prioritization) + +This is the clean stopping point. Phase 2b covers terrain atlas, neighbor landblocks, ICamera + FlyCamera, and the IGameState/IEvents plugin API. + +--- + +## Phase 2a done criteria (Tasks 1–10) + +1. `dotnet build` — clean, 0 warnings. +2. `dotnet test` — all pass (expected 38+ after Tasks 2, 3, 4, 5, 6 add their tests). +3. `dotnet run --project src/AcDream.App -- "references/Asheron's Call"` — window opens, shows terrain WITH visible building/static-object geometry, at least some with real textures. +4. No exceptions in the console. +5. 9–10 task commits on `phase-2/static-meshes-and-textures` branch. + +--- + +## Tasks 11–18 (sketch — re-plan in Phase 2b session) + +**These are intentionally NOT fully specified. They're outlined so a future session has a starting point.** + +### Task 11: Terrain atlas texture + updated terrain shader + +- Gather unique terrain type ids from all loaded landblock `TerrainInfo[]` arrays +- Decode each via `SurfaceDecoder` and upload as a `GL_TEXTURE_2D_ARRAY` +- Add `aTerrainIndex` vertex attribute to `LandblockMesh.Build` +- Update `terrain.vert` to pass it through as `flat uint` +- Update `terrain.frag` to sample `sampler2DArray uTerrainAtlas` with `vTerrainIndex` + +### Task 12: Render 3×3 neighbor landblocks + +- Replace `LandblockLoader.Load` call in `GameWindow` with `WorldView.Load` +- Pass each landblock's origin as a `uModel` uniform offset +- Concatenate all 9 landblocks' entities into one draw list + +### Task 13: Extract `ICamera` interface, refactor `OrbitCamera` + +- Add `src/AcDream.App/Rendering/ICamera.cs` +- Change `OrbitCamera` to implement it +- Change `TerrainRenderer.Draw` and `StaticMeshRenderer.Draw` to take `ICamera` not `OrbitCamera` + +### Task 14: `FlyCamera` (WASD + mouse look) + +- New `FlyCamera : ICamera` +- Per-frame input read in `GameWindow.OnUpdate` +- Mouse cursor capture via `CursorMode.Raw` + +### Task 15: `CameraController` + F key toggle + +- Owns both cameras, exposes `Active` +- F toggles, also flips cursor capture + +### Task 16: `IGameState` + `IEvents` in abstractions + +- Add `src/AcDream.Plugin.Abstractions/IGameState.cs`, `IEvents.cs`, `WorldEntitySnapshot.cs` +- Extend `IPluginHost` with `State` and `Events` properties +- Update `IPluginHostImpl` in tests + +### Task 17: `WorldGameState` + `WorldEvents` + wire into `AppPluginHost` + +- Concrete implementations under `src/AcDream.App/Plugins/` +- `AppPluginHost` gains `State` and `Events` +- `Program.cs` builds `WorldGameState` from the `WorldView` after loading, then wires into `AppPluginHost` BEFORE plugin Initialize + +### Task 18: Extend `SmokePlugin` to subscribe + +- `SmokePlugin.Enable` subscribes to `Events.EntitySpawned` +- Logs the entity count at enable time + a message per spawn +- End-to-end smoke: console shows `smoke plugin sees N entities` on startup + +--- + +## Relevant skills to invoke during execution + +- `superpowers:executing-plans` — to walk through this plan task by task +- `superpowers:test-driven-development` — for Tasks 2, 3, 4, 5, 6 (TDD-driven) +- `superpowers:systematic-debugging` — for Task 9 (the debug task) +- `superpowers:verification-before-completion` — before marking each task complete