acdream/docs/plans/2026-04-10-phase-2-static-meshes-plan.md
Erik af68c56b91 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) <noreply@anthropic.com>
2026-04-10 17:27:53 +02:00

72 KiB
Raw Blame History

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

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.

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:

<PackageReference Include="BCnEncoder.Net" Version="2.2.1" />

The final ItemGroup should look like:

<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

dotnet restore
dotnet build

Expected: 0 warnings, 0 errors.

dotnet test

Expected: Passed: 17, Failed: 0.

Step 5: Commit

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):

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):

public partial class SWVertex : IDatObjType {
    public Vector3 Origin;
    public Vector3 Normal;
    public List<Vec2Duv> UVs = [];  // a single position can have multiple UV sets
}

Vec2Duv:

public partial class Vec2Duv : IDatObjType {
    public float U;
    public float V;
}

Polygon (from Generated/Types/Polygon.generated.cs):

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:

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.

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

dotnet test --filter "FullyQualifiedName~GfxObjMeshTests"

Expected: compile errors for GfxObjMesh. RED state.

Step 4: Implement

Create src/AcDream.Core/Meshing/GfxObjMesh.cs:

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

dotnet test --filter "FullyQualifiedName~GfxObjMeshTests"

Expected: Passed: 6, Failed: 0.

Full suite:

dotnet test

Expected: Passed: 23, Failed: 0 (17 + 6).

Step 6: Commit

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):

public partial class Surface : DBObj {
    public SurfaceType Type;
    public QualifiedDataId<SurfaceTexture> OrigTextureId;  // ptr to SurfaceTexture
    // ...
}

SurfaceTexture:

public partial class SurfaceTexture : DBObj {
    public List<QualifiedDataId<RenderSurface>> Textures = [];  // typically one entry
    // ...
}

RenderSurface holds the raw pixel bytes and format:

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):

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, DXT3Bc2, DXT5Bc3.

Step 1: Define output type

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

// 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 SurfaceSurfaceTextureRenderSurface 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

dotnet test --filter "FullyQualifiedName~SurfaceDecoderTests"

Expected: compile errors for SurfaceDecoder. RED.

Step 4: Implement

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

dotnet test --filter "FullyQualifiedName~SurfaceDecoderTests"

Expected: Passed: 4, Failed: 0.

Full suite: Passed: 27, Failed: 0.

Step 6: Commit

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:

public partial class LandBlockInfo : DBObj {
    public List<Stab> Objects = [];           // small decorations
    public List<BuildingInfo> Buildings = []; // buildings
}

Stab:

public partial class Stab : IDatObjType {
    public uint Id;      // GfxObj or Setup id; top byte tells which
    public Frame Frame;  // .Origin + .Orientation
}

BuildingInfo:

public partial class BuildingInfo : IDatObjType {
    public uint ModelId;
    public Frame Frame;
    public List<BuildingPortal> Portals = [];
}

Frame:

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

// src/AcDream.Core/World/MeshRef.cs
using System.Numerics;

namespace AcDream.Core.World;

public readonly record struct MeshRef(uint GfxObjId, Matrix4x4 PartTransform);
// 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; }
}
// 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

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

dotnet test --filter "FullyQualifiedName~LandblockLoaderTests"

Expected: RED.

Step 4: Implement

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

dotnet test --filter "FullyQualifiedName~LandblockLoaderTests"

Expected: Passed: 4.

Full suite: 31 passing.

Step 6: Commit

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:

`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

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

dotnet test --filter "FullyQualifiedName~SetupMeshTests"

Step 3: Implement

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

dotnet test --filter "FullyQualifiedName~SetupMeshTests"

Expected: Passed: 4.

Full suite: 35 passing.

Step 5: Commit

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

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

dotnet test --filter "FullyQualifiedName~WorldViewTests"

Step 3: Implement

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

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

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

dotnet build

Expected: 0 warnings, 0 errors.

Step 3: Commit

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

// 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);
}
// 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

// 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):

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:

_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:

_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:

_staticMesh?.Draw(_camera!, _entities);

In OnClosing, before disposing _dats:

_staticMesh?.Dispose();
_textureCache?.Dispose();
_meshShader?.Dispose();

Step 4: Build

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

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

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

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

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

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 110)

  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. 910 task commits on phase-2/static-meshes-and-textures branch.

Tasks 1118 (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