# 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
<PackageReference Include="BCnEncoder.Net" Version="2.2.1" />
```

The final ItemGroup should look like:

```xml
<ItemGroup>
  <PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.4" />
  <PackageReference Include="Serilog" Version="4.0.2" />
  <PackageReference Include="BCnEncoder.Net" Version="2.2.1" />
</ItemGroup>
```

**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<QualifiedDataId<Surface>> Surfaces = [];   // multiple surfaces allowed
    public VertexArray VertexArray;                         // .Vertices is Dictionary<ushort, SWVertex>
    public Dictionary<ushort, Polygon> 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<Vec2Duv> 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<short> VertexIds = [];       // position indices into VertexArray.Vertices
    public List<byte> PosUVIndices = [];     // for each VertexIds[i], which UV slot to use
    public List<byte> NegUVIndices = [];     // backface UVs; ignored in Phase 2
}
```

`QualifiedDataId<T>` 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;

/// <summary>
/// One sub-mesh of a GfxObj: a vertex+index buffer that uses a single Surface.
/// A GfxObj with multiple surfaces produces multiple sub-meshes.
/// </summary>
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
{
    /// <summary>
    /// Build a minimal GfxObj fixture with a single triangle using surface index 0.
    /// Three unique positions, one UV slot each.
    /// </summary>
    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
{
    /// <summary>
    /// Walk a GfxObj's polygons and produce one <see cref="GfxObjSubMesh"/>
    /// per referenced Surface. Polygons are triangulated as fans.
    /// </summary>
    public static IReadOnlyList<GfxObjSubMesh> Build(GfxObj gfxObj)
    {
        // Group output vertices and indices per surface index.
        var perSurface = new Dictionary<int, (List<Vertex> Vertices, List<uint> 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<Vertex>(), new List<uint>(), new Dictionary<(int, int), uint>());
                perSurface[surfaceIdx] = bucket;
            }

            // Collect output vertex indices for this polygon.
            var polyOut = new List<uint>(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<GfxObjSubMesh>(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<SurfaceTexture> OrigTextureId;  // ptr to SurfaceTexture
    // ...
}
```

`SurfaceTexture`:
```csharp
public partial class SurfaceTexture : DBObj {
    public List<QualifiedDataId<RenderSurface>> 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)
{
    /// <summary>1x1 magenta fallback for missing/unsupported textures.</summary>
    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();

    /// <summary>
    /// Decode a RenderSurface's pixel bytes into RGBA8. Returns <see cref="DecodedTexture.Magenta"/>
    /// for unsupported formats, null data, or corrupt sizing.
    /// </summary>
    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<Stab> Objects = [];           // small decorations
    public List<BuildingInfo> 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<BuildingPortal> 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<MeshRef> 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<WorldEntity> 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;

    /// <summary>
    /// Load a single landblock (heightmap + static objects) from the dats.
    /// </summary>
    /// <returns>Null if the landblock is missing from the cell dat.</returns>
    public static LoadedLandblock? Load(DatCollection dats, uint landblockId)
    {
        var block = dats.Get<LandBlock>(landblockId);
        if (block is null)
            return null;

        var info = dats.Get<LandBlockInfo>((landblockId & 0xFFFF0000u) | 0xFFFEu);
        var entities = info is null
            ? Array.Empty<WorldEntity>()
            : BuildEntitiesFromInfo(info);

        return new LoadedLandblock(landblockId, block, entities);
    }

    /// <summary>
    /// 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.
    /// </summary>
    public static IReadOnlyList<WorldEntity> BuildEntitiesFromInfo(LandBlockInfo info)
    {
        var result = new List<WorldEntity>(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<MeshRef>(),
            });
        }

        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<MeshRef>(),
            });
        }

        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<QualifiedDataId<GfxObj>>` lists the constituent GfxObjs. `ParentIndex: List<uint>` gives the parent part index for each part (forming a tree). `DefaultScale: List<Vector3>` gives the per-part scale. `PlacementFrames: Dictionary<Placement, AnimationFrame>` 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`:**

<quick-fact>
`AnimationFrame` is `public partial class AnimationFrame : IDatObjType { public List<Frame> Frames = []; }` — a list of per-part `Frame` objects. Each part's frame is at index `i` matching `Setup.Parts[i]`.
</quick-fact>

### 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
{
    /// <summary>
    /// 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.
    /// </summary>
    public static IReadOnlyList<MeshRef> Flatten(Setup setup)
    {
        AnimationFrame? defaultAnim = null;
        if (setup.PlacementFrames.TryGetValue(Placement.Default, out var af))
            defaultAnim = af;

        var result = new List<MeshRef>(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<LoadedLandblock> Landblocks { get; }
    public IEnumerable<WorldEntity> AllEntities => Landblocks.SelectMany(lb => lb.Entities);

    private WorldView(uint centerLandblockId, IReadOnlyList<LoadedLandblock> landblocks)
    {
        CenterLandblockId = centerLandblockId;
        Landblocks = landblocks;
    }

    /// <summary>
    /// Load the 3x3 grid of landblocks around <paramref name="centerLandblockId"/>.
    /// Missing neighbors (edges of the world or absent from the cell dat) are silently skipped.
    /// </summary>
    public static WorldView Load(DatCollection dats, uint centerLandblockId)
    {
        var loaded = new List<LoadedLandblock>();
        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);
    }

    /// <summary>
    /// 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).
    /// </summary>
    public static IEnumerable<uint> 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<uint, uint> _handlesBySurfaceId = new();
    private uint _magentaHandle;

    public TextureCache(GL gl, DatCollection dats)
    {
        _gl = gl;
        _dats = dats;
    }

    /// <summary>
    /// 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.
    /// </summary>
    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<Surface>(surfaceId);
        if (surface is null)
            return DecodedTexture.Magenta;

        var surfaceTexture = _dats.Get<SurfaceTexture>((uint)surface.OrigTextureId);
        if (surfaceTexture is null || surfaceTexture.Textures.Count == 0)
            return DecodedTexture.Magenta;

        var rs = _dats.Get<RenderSurface>((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 `<None Update="Rendering\Shaders\*.*">` 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<uint, List<SubMeshGpu>> _gpuByGfxObj = new();

    public StaticMeshRenderer(GL gl, Shader shader, TextureCache textures)
    {
        _gl = gl;
        _shader = shader;
        _textures = textures;
    }

    public void EnsureUploaded(uint gfxObjId, IReadOnlyList<GfxObjSubMesh> subMeshes)
    {
        if (_gpuByGfxObj.ContainsKey(gfxObjId))
            return;

        var list = new List<SubMeshGpu>(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<WorldEntity> 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<DatReaderWriter.DBObjs.LandBlockInfo>((landblockId & 0xFFFF0000u) | 0xFFFEu);
var entities = info is not null
    ? AcDream.Core.World.LandblockLoader.BuildEntitiesFromInfo(info)
    : Array.Empty<AcDream.Core.World.WorldEntity>();

// 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<AcDream.Core.World.WorldEntity>(entities.Count);
foreach (var e in entities)
{
    var meshRefs = new List<AcDream.Core.World.MeshRef>();

    if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u)
    {
        // GfxObj: one mesh ref with identity transform.
        var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(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<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
        if (setup is not null)
        {
            var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
            foreach (var mr in flat)
            {
                var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(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<AcDream.Core.World.WorldEntity> _entities = Array.Empty<AcDream.Core.World.WorldEntity>();`

**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<T>` not `TryGet<T>`.

### 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 <N> 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 <affected files>
git commit -m "fix(textures): <specific fix>"
```

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 <N> 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
