acdream/docs/plans/2026-04-10-phase-2b-plan.md

54 KiB

Phase 2b Implementation Plan — Atlas Textures, Neighbors, Dual Cameras, Plugin API Growth

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.

Goal: Turn Phase 2a's single textured Holtburg landblock into a 3x3 neighbor grid with real terrain textures from the dats, explorable with a first-person FlyCamera (F toggle, raw cursor), and expose the world entity list to plugins via IGameState + IEvents with replay-on-subscribe.

Architecture: Vertex struct grows a TerrainLayer field. TerrainAtlas builds a GL_TEXTURE_2D_ARRAY from Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc. Terrain shader samples sampler2DArray with a flat uint layer attribute. WorldView.Load drives neighbor rendering via a uModel uniform per landblock. ICamera interface unifies OrbitCamera + new FlyCamera; CameraController.F toggles and manages cursor capture. Plugin abstractions grow by IGameState, IEvents, WorldEntitySnapshot. WorldEvents implements replay-on-subscribe so late subscribers see every already-spawned entity before += returns.

Tech Stack: .NET 10, Silk.NET 2.23.0, Chorizite.DatReaderWriter 2.1.4, BCnEncoder.Net 2.2.1, xUnit.

Context: Read docs/plans/2026-04-10-phase-2b-design.md for the full design. Phase 2a merged on main as 1d1e668 + fix-up cc55c3f. The Vertex struct in src/AcDream.Core/Terrain/Vertex.cs currently has 8 floats (Position, Normal, TexCoord); Phase 2b adds a 9th value (uint TerrainLayer) → 36-byte stride. Both StaticMeshRenderer and TerrainRenderer must update their VertexAttribPointer calls to match. The retail dat directory lives at references/Asheron's Call/ (gitignored).

Work branch: phase-2b/atlas-neighbors-cameras-events (Task 0 creates it).

Testing philosophy: TDD the pure CPU pieces (LandblockMesh updated tests, FlyCamera math, WorldEvents replay). Manual smoke the GL-coupled pieces (terrain atlas rendering, neighbor visibility, FlyCamera input). 42 tests from Phase 2a must stay green.

Commit cadence: One commit per task minimum. Never batch tasks.

Stopping point for this session: Task 9 completes Phase 2b. Merge to main after end-to-end smoke confirms clean startup.


Task 0: Branch off main

Files: none

  • Step 1: Verify on main, clean working tree:
git checkout main
git status

Expected: On branch main, nothing to commit, working tree clean.

  • Step 2: Create feature branch:
git checkout -b phase-2b/atlas-neighbors-cameras-events
  • Step 3: Sanity build + test:
dotnet build
dotnet test

Expected: 0 warnings, 0 errors, 42 tests passing.

No commit for this task — just branch setup.


Task 1: Expand Vertex struct + LandblockMesh.Build signature

Files:

  • Modify: src/AcDream.Core/Terrain/Vertex.cs
  • Modify: src/AcDream.Core/Terrain/LandblockMesh.cs
  • Modify: tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs
  • Modify: src/AcDream.Core/Meshing/GfxObjMesh.cs (write TerrainLayer = 0 for static mesh vertices)
  • Modify: src/AcDream.App/Rendering/StaticMeshRenderer.cs (new VertexAttribPointer for attribute 3)
  • Modify: src/AcDream.App/Rendering/TerrainRenderer.cs (new VertexAttribPointer for attribute 3)

Step 1: Write the failing test

Add this test to tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs:

[Fact]
public void Build_PerVertexTerrainLayer_UsesMappedLayerIndex()
{
    var block = BuildFlatLandBlock();
    // TerrainInfo is a struct with implicit conversion from ushort. The low 5 bits
    // of the ushort encode TerrainTextureType via TerrainInfo.Type.
    // Set vertex at x-major index (x=2, y=3) to terrain type 7.
    block.Terrain[2 * 9 + 3] = (ushort)7;  // low 5 bits = 7

    var map = new Dictionary<uint, uint>
    {
        [0] = 0u,   // default type → atlas layer 0
        [7] = 4u,   // type 7 → atlas layer 4
    };

    var mesh = LandblockMesh.Build(block, IdentityHeightTable, map);

    // Vertex buffer internal order is y*9+x, so vertex at world (x=2, y=3) is at
    // index 3*9+2 = 29.
    Assert.Equal(4u, mesh.Vertices[3 * 9 + 2].TerrainLayer);
    // An untouched vertex still has type 0, maps to layer 0.
    Assert.Equal(0u, mesh.Vertices[0].TerrainLayer);
}

Step 2: Run test to verify it fails

dotnet test --filter "FullyQualifiedName~LandblockMeshTests.Build_PerVertexTerrainLayer"

Expected: compile error — Vertex does not contain a definition for TerrainLayer, or Build signature mismatch.

Step 3: Update Vertex struct

Replace src/AcDream.Core/Terrain/Vertex.cs with:

using System.Numerics;

namespace AcDream.Core.Terrain;

public readonly record struct Vertex(
    Vector3 Position,
    Vector3 Normal,
    Vector2 TexCoord,
    uint TerrainLayer);

Step 4: Update LandblockMesh.Build signature

Replace src/AcDream.Core/Terrain/LandblockMesh.cs body:

using System.Numerics;
using DatReaderWriter.DBObjs;

namespace AcDream.Core.Terrain;

public sealed record LandblockMeshData(Vertex[] Vertices, uint[] Indices);

public static class LandblockMesh
{
    private const int VerticesPerSide = 9;
    private const int CellsPerSide = VerticesPerSide - 1;
    private const float CellSize = 24.0f;
    // Phase 2b: tile terrain textures ~4x per landblock instead of stretching
    // a single texture across the whole 192-unit patch.
    private const float TexCoordDivisor = CellsPerSide / 4.0f;

    public static LandblockMeshData Build(
        LandBlock block,
        float[] heightTable,
        IReadOnlyDictionary<uint, uint> terrainTypeToLayer)
    {
        ArgumentNullException.ThrowIfNull(heightTable);
        ArgumentNullException.ThrowIfNull(terrainTypeToLayer);
        if (heightTable.Length < 256)
            throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));

        var vertices = new Vertex[VerticesPerSide * VerticesPerSide];
        for (int y = 0; y < VerticesPerSide; y++)
        {
            for (int x = 0; x < VerticesPerSide; x++)
            {
                int vi = y * VerticesPerSide + x;
                int hi = x * VerticesPerSide + y;

                float height = heightTable[block.Height[hi]];

                // TerrainInfo.Type returns the TerrainTextureType enum; cast to uint
                // for the atlas-layer map lookup.
                uint terrainType = (uint)block.Terrain[hi].Type;
                if (!terrainTypeToLayer.TryGetValue(terrainType, out var layer))
                    layer = 0;

                vertices[vi] = new Vertex(
                    Position: new Vector3(x * CellSize, y * CellSize, height),
                    Normal: Vector3.UnitZ,
                    TexCoord: new Vector2(x / TexCoordDivisor, y / TexCoordDivisor),
                    TerrainLayer: layer);
            }
        }

        var indices = new uint[CellsPerSide * CellsPerSide * 6];
        int idx = 0;
        for (int y = 0; y < CellsPerSide; y++)
        {
            for (int x = 0; x < CellsPerSide; x++)
            {
                uint a = (uint)(y * VerticesPerSide + x);
                uint b = (uint)(y * VerticesPerSide + x + 1);
                uint c = (uint)((y + 1) * VerticesPerSide + x);
                uint d = (uint)((y + 1) * VerticesPerSide + x + 1);
                indices[idx++] = a; indices[idx++] = b; indices[idx++] = d;
                indices[idx++] = a; indices[idx++] = d; indices[idx++] = c;
            }
        }

        return new LandblockMeshData(vertices, indices);
    }
}

Step 5: Update existing LandblockMesh tests to pass an empty map

In tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs, every existing LandblockMesh.Build(block, IdentityHeightTable) call must become:

LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap)

Add a static field near IdentityHeightTable:

private static readonly IReadOnlyDictionary<uint, uint> EmptyTerrainMap =
    new Dictionary<uint, uint>();

The 4 existing tests plus the new Build_PerVertexTerrainLayer_UsesMappedLayerIndex test should all compile.

Step 6: Update GfxObjMesh to write TerrainLayer = 0

In src/AcDream.Core/Meshing/GfxObjMesh.cs, find the new Vertex(sw.Origin, sw.Normal, texcoord) call and change it to:

bucket.Vertices.Add(new Vertex(sw.Origin, sw.Normal, texcoord, TerrainLayer: 0));

GfxObj meshes don't use the terrain atlas; the mesh shader ignores TerrainLayer. Layer 0 is a safe no-op because the mesh shader binds its own sampler2D uDiffuse and doesn't touch the atlas.

Step 7: Update VertexAttribPointer calls in renderers

In both src/AcDream.App/Rendering/TerrainRenderer.cs and src/AcDream.App/Rendering/StaticMeshRenderer.cs (in the UploadSubMesh method for the latter), after the existing three EnableVertexAttribArray / VertexAttribPointer calls for locations 0/1/2, add:

_gl.EnableVertexAttribArray(3);
_gl.VertexAttribIPointer(3, 1, VertexAttribIType.UnsignedInt, stride, (void*)(8 * sizeof(float)));

Note: VertexAttribIPointer not VertexAttribPointer — the I variant is required for integer-typed vertex attributes in GLSL. Also note VertexAttribIType.UnsignedInt, not VertexAttribPointerType.UnsignedInt. Silk.NET exposes both.

The stride is (uint)sizeof(Vertex) which will now be 36 after the struct change, automatically computed — no hardcoded stride to update.

Step 8: Run all tests

dotnet test

Expected: Passed: 43 (42 previous + 1 new). All existing tests pass because EmptyTerrainMap maps nothing → all vertices get TerrainLayer = 0, which is compatible with their previous Vertex construction shape.

Step 9: Build and verify runtime still works

dotnet build

Expected: 0 warnings, 0 errors.

Do NOT run the app yet — the current terrain shader doesn't know about aTerrainLayer, which OpenGL will happily ignore since the vertex attribute is unbound from the shader side. But Phase 2b Task 3 rewrites the shader; for now just confirm the build is clean.

Step 10: Commit

git add -A
git commit -m "feat(core): add Vertex.TerrainLayer + LandblockMesh layer map"

Task 2: TerrainAtlas class

Files:

  • Create: src/AcDream.App/Rendering/TerrainAtlas.cs

No unit tests — this is GL-coupled code with a dat dependency. Manual smoke is Task 4.

Step 1: Write the class

Create src/AcDream.App/Rendering/TerrainAtlas.cs:

using AcDream.Core.Textures;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using Silk.NET.OpenGL;

namespace AcDream.App.Rendering;

/// <summary>
/// Builds a GL_TEXTURE_2D_ARRAY from the set of terrain types seen in the loaded
/// landblocks, one layer per unique terrain type. LandblockMesh writes per-vertex
/// layer indices into Vertex.TerrainLayer; the terrain fragment shader samples
/// texture(uAtlas, vec3(uv, float(vLayer))).
/// </summary>
public sealed unsafe class TerrainAtlas : IDisposable
{
    private readonly GL _gl;
    public uint GlTexture { get; }
    public IReadOnlyDictionary<uint, uint> TerrainTypeToLayer { get; }
    public int LayerCount { get; }

    private TerrainAtlas(GL gl, uint glTexture, IReadOnlyDictionary<uint, uint> map, int layerCount)
    {
        _gl = gl;
        GlTexture = glTexture;
        TerrainTypeToLayer = map;
        LayerCount = layerCount;
    }

    /// <summary>
    /// Build the atlas by walking Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc
    /// for the mapping from TerrainTextureType to SurfaceTexture id, decoding each
    /// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY.
    /// </summary>
    public static TerrainAtlas Build(GL gl, DatCollection dats)
    {
        var region = dats.Get<Region>(0x13000000u)
            ?? throw new InvalidOperationException("Region dat id 0x13000000 missing");

        var terrainDesc = region.TerrainInfo?.LandSurfaces?.TexMerge?.TerrainDesc;
        if (terrainDesc is null || terrainDesc.Count == 0)
        {
            // Fallback: upload a single 1x1 white layer as layer 0.
            Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer");
            return BuildFallback(gl);
        }

        // Walk TerrainDesc. Each TMTerrainDesc has a TerrainType (enum cast to uint)
        // and a TerrainTex with a QualifiedDataId<SurfaceTexture> TextureId. Decode
        // each referenced SurfaceTexture → RenderSurface → RGBA8 via SurfaceDecoder.
        var decodedByType = new Dictionary<uint, DecodedTexture>();
        int maxW = 1, maxH = 1;
        foreach (var tmtd in terrainDesc)
        {
            uint typeKey = (uint)tmtd.TerrainType;
            if (decodedByType.ContainsKey(typeKey))
                continue;

            var surfaceTextureId = (uint)tmtd.TerrainTex.TextureId;
            var st = dats.Get<SurfaceTexture>(surfaceTextureId);
            if (st is null || st.Textures.Count == 0)
            {
                Console.WriteLine($"WARN: TerrainType {tmtd.TerrainType} SurfaceTexture 0x{surfaceTextureId:X8} missing");
                decodedByType[typeKey] = DecodedTexture.Magenta;
                continue;
            }

            var rs = dats.Get<RenderSurface>((uint)st.Textures[0]);
            if (rs is null)
            {
                decodedByType[typeKey] = DecodedTexture.Magenta;
                continue;
            }

            Palette? palette = rs.DefaultPaletteId != 0
                ? dats.Get<Palette>(rs.DefaultPaletteId)
                : null;

            var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette);
            decodedByType[typeKey] = decoded;
            if (decoded.Width > maxW) maxW = decoded.Width;
            if (decoded.Height > maxH) maxH = decoded.Height;
        }

        // Allocate the GL_TEXTURE_2D_ARRAY with the max dimensions seen. Textures
        // smaller than (maxW, maxH) are scaled up naively by nearest-neighbor
        // replication into a resized RGBA8 buffer. Phase 2b doesn't need mip chains.
        int layerCount = decodedByType.Count;
        uint tex = gl.GenTexture();
        gl.BindTexture(TextureTarget.Texture2DArray, tex);
        gl.TexImage3D(
            TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
            (uint)maxW, (uint)maxH, (uint)layerCount,
            0, PixelFormat.Rgba, PixelType.UnsignedByte, null);

        var map = new Dictionary<uint, uint>();
        int layerIdx = 0;
        foreach (var kvp in decodedByType)
        {
            byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH);
            fixed (byte* p = buffer)
            {
                gl.TexSubImage3D(
                    TextureTarget.Texture2DArray, 0,
                    0, 0, layerIdx,
                    (uint)maxW, (uint)maxH, 1,
                    PixelFormat.Rgba, PixelType.UnsignedByte, p);
            }
            map[kvp.Key] = (uint)layerIdx;
            layerIdx++;
        }

        gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
        gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
        gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
        gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);

        gl.BindTexture(TextureTarget.Texture2DArray, 0);

        Console.WriteLine($"TerrainAtlas: {layerCount} layers at {maxW}x{maxH}");
        return new TerrainAtlas(gl, tex, map, layerCount);
    }

    private static byte[] ResizeRgba8Nearest(DecodedTexture src, int dstW, int dstH)
    {
        if (src.Width == dstW && src.Height == dstH)
            return src.Rgba8;

        var dst = new byte[dstW * dstH * 4];
        for (int y = 0; y < dstH; y++)
        {
            int srcY = y * src.Height / dstH;
            for (int x = 0; x < dstW; x++)
            {
                int srcX = x * src.Width / dstW;
                int si = (srcY * src.Width + srcX) * 4;
                int di = (y * dstW + x) * 4;
                dst[di + 0] = src.Rgba8[si + 0];
                dst[di + 1] = src.Rgba8[si + 1];
                dst[di + 2] = src.Rgba8[si + 2];
                dst[di + 3] = src.Rgba8[si + 3];
            }
        }
        return dst;
    }

    private static TerrainAtlas BuildFallback(GL gl)
    {
        uint tex = gl.GenTexture();
        gl.BindTexture(TextureTarget.Texture2DArray, tex);
        var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF };
        gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, PixelFormat.Rgba, PixelType.UnsignedByte, null);
        fixed (byte* p = white)
            gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, PixelFormat.Rgba, PixelType.UnsignedByte, p);
        gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
        gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
        gl.BindTexture(TextureTarget.Texture2DArray, 0);
        return new TerrainAtlas(gl, tex, new Dictionary<uint, uint> { [0] = 0u }, 1);
    }

    public void Dispose() => _gl.DeleteTexture(GlTexture);
}

Step 2: Build

dotnet build

Expected: 0 warnings, 0 errors. This is compile-only verification; runtime exercise is Task 4.

Step 3: Commit

git add src/AcDream.App/Rendering/TerrainAtlas.cs
git commit -m "feat(app): add TerrainAtlas for GL_TEXTURE_2D_ARRAY terrain textures"

Task 3: Rewrite terrain shader + TerrainRenderer per-landblock uModel

Files:

  • Modify: src/AcDream.App/Rendering/Shaders/terrain.vert
  • Modify: src/AcDream.App/Rendering/Shaders/terrain.frag
  • Modify: src/AcDream.App/Rendering/TerrainRenderer.cs

Step 1: Update terrain.vert

Replace src/AcDream.App/Rendering/Shaders/terrain.vert:

#version 430 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTex;
layout(location = 3) in uint aTerrainLayer;

uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProjection;

out vec2 vTex;
out flat uint vLayer;

void main() {
    vTex = aTex;
    vLayer = aTerrainLayer;
    gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
}

Step 2: Update terrain.frag

Replace src/AcDream.App/Rendering/Shaders/terrain.frag:

#version 430 core
in vec2 vTex;
in flat uint vLayer;
out vec4 fragColor;

uniform sampler2DArray uAtlas;

void main() {
    fragColor = texture(uAtlas, vec3(vTex, float(vLayer)));
}

Step 3: Update TerrainRenderer to take multiple landblocks + atlas

Replace src/AcDream.App/Rendering/TerrainRenderer.cs:

using System.Numerics;
using AcDream.Core.Terrain;
using Silk.NET.OpenGL;

namespace AcDream.App.Rendering;

public sealed unsafe class TerrainRenderer : IDisposable
{
    private readonly GL _gl;
    private readonly Shader _shader;
    private readonly TerrainAtlas _atlas;
    private readonly List<LandblockGpu> _landblocks = new();

    public TerrainRenderer(GL gl, Shader shader, TerrainAtlas atlas)
    {
        _gl = gl;
        _shader = shader;
        _atlas = atlas;
    }

    public void AddLandblock(LandblockMeshData meshData, Vector3 worldOrigin)
    {
        var gpu = new LandblockGpu
        {
            Vao = _gl.GenVertexArray(),
            WorldOrigin = worldOrigin,
            IndexCount = meshData.Indices.Length,
        };

        _gl.BindVertexArray(gpu.Vao);

        gpu.Vbo = _gl.GenBuffer();
        _gl.BindBuffer(BufferTargetARB.ArrayBuffer, gpu.Vbo);
        fixed (void* p = meshData.Vertices)
            _gl.BufferData(BufferTargetARB.ArrayBuffer,
                (nuint)(meshData.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);

        gpu.Ebo = _gl.GenBuffer();
        _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, gpu.Ebo);
        fixed (void* p = meshData.Indices)
            _gl.BufferData(BufferTargetARB.ElementArrayBuffer,
                (nuint)(meshData.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.EnableVertexAttribArray(3);
        _gl.VertexAttribIPointer(3, 1, VertexAttribIType.UnsignedInt, stride, (void*)(8 * sizeof(float)));

        _gl.BindVertexArray(0);
        _landblocks.Add(gpu);
    }

    public void Draw(OrbitCamera camera)  // ICamera in Task 5
    {
        _shader.Use();
        _shader.SetMatrix4("uView", camera.View);
        _shader.SetMatrix4("uProjection", camera.Projection);

        _gl.ActiveTexture(TextureUnit.Texture0);
        _gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlTexture);

        foreach (var lb in _landblocks)
        {
            var model = Matrix4x4.CreateTranslation(lb.WorldOrigin);
            _shader.SetMatrix4("uModel", model);
            _gl.BindVertexArray(lb.Vao);
            _gl.DrawElements(PrimitiveType.Triangles, (uint)lb.IndexCount, DrawElementsType.UnsignedInt, (void*)0);
        }
        _gl.BindVertexArray(0);
    }

    public void Dispose()
    {
        foreach (var lb in _landblocks)
        {
            _gl.DeleteBuffer(lb.Vbo);
            _gl.DeleteBuffer(lb.Ebo);
            _gl.DeleteVertexArray(lb.Vao);
        }
        _landblocks.Clear();
    }

    private sealed class LandblockGpu
    {
        public uint Vao;
        public uint Vbo;
        public uint Ebo;
        public int IndexCount;
        public Vector3 WorldOrigin;
    }
}

Note: TerrainRenderer.Draw still takes OrbitCamera — Task 5 changes it to ICamera.

Step 4: Build

dotnet build

Expected: build FAILS because GameWindow.cs still calls new TerrainRenderer(_gl, meshData, _shader) and _terrain.Draw(_camera) which don't match the new signatures. This is expected — Task 4 fixes GameWindow.

Stop here and do Task 4 next. Do NOT commit a broken build.


Task 4: Wire WorldView.Load + neighbors into GameWindow

Files:

  • Modify: src/AcDream.App/Rendering/GameWindow.cs

Step 1: Replace the single-landblock load with WorldView + 3x3 neighbors

Find the block in GameWindow.OnLoad that starts with uint landblockId = 0xA9B4FFFFu; and ends after _terrain = new TerrainRenderer(_gl, meshData, _shader);. Replace the whole block with:

uint centerLandblockId = 0xA9B4FFFFu;
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");

var region = _dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
var heightTable = region?.LandDefs.LandHeightTable;
if (heightTable is null || heightTable.Length < 256)
    throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated");

// Build the terrain atlas once from the Region dat.
var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats);

_terrain = new TerrainRenderer(_gl, _shader, terrainAtlas);

// Load the 3x3 neighbor grid.
var worldView = AcDream.Core.World.WorldView.Load(_dats, centerLandblockId);
Console.WriteLine($"loaded {worldView.Landblocks.Count} landblocks in 3x3 grid");

int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);

foreach (var lb in worldView.Landblocks)
{
    var meshData = AcDream.Core.Terrain.LandblockMesh.Build(
        lb.Heightmap, heightTable, terrainAtlas.TerrainTypeToLayer);

    // Compute world origin for this landblock relative to the center.
    int lbX = (int)((lb.LandblockId >> 24) & 0xFFu);
    int lbY = (int)((lb.LandblockId >> 16) & 0xFFu);
    var origin = new System.Numerics.Vector3(
        (lbX - centerX) * 192f,
        (lbY - centerY) * 192f,
        0f);

    _terrain.AddLandblock(meshData, origin);
}

Step 2: Replace the entity hydration block to use WorldView.AllEntities

Find the existing block that does var info = _dats.Get<LandBlockInfo>(...); var entities = info is not null ? LandblockLoader.BuildEntitiesFromInfo(info) : ... and replace with:

_textureCache = new TextureCache(_gl, _dats);
_staticMesh = new StaticMeshRenderer(_gl, _meshShader, _textureCache);

// Hydrate entities from ALL loaded landblocks, not just the center.
var allEntities = worldView.AllEntities.ToList();
Console.WriteLine($"hydrating {allEntities.Count} entities across {worldView.Landblocks.Count} landblocks");

var hydratedEntities = new List<AcDream.Core.World.WorldEntity>(allEntities.Count);
foreach (var e in allEntities)
{
    var meshRefs = new List<AcDream.Core.World.MeshRef>();

    if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u)
    {
        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)
    {
        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)
    {
        // Add the landblock origin to the entity's position so the static
        // mesh renderer draws it at the correct world location.
        var sourceLandblock = worldView.Landblocks.First(lb => lb.Entities.Contains(e));
        int lbX = (int)((sourceLandblock.LandblockId >> 24) & 0xFFu);
        int lbY = (int)((sourceLandblock.LandblockId >> 16) & 0xFFu);
        var worldOffset = new System.Numerics.Vector3(
            (lbX - centerX) * 192f,
            (lbY - centerY) * 192f,
            0f);

        hydratedEntities.Add(new AcDream.Core.World.WorldEntity
        {
            Id = e.Id,
            SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId,
            Position = e.Position + worldOffset,
            Rotation = e.Rotation,
            MeshRefs = meshRefs,
        });
    }
}

_entities = hydratedEntities;
Console.WriteLine($"hydrated {_entities.Count} entities");

Step 3: Build

dotnet build

Expected: 0 warnings, 0 errors.

Step 4: Run against real dats (manual smoke)

dotnet run --project src/AcDream.App -- "references/Asheron's Call"

Expected console output:

loading world view centered on 0xA9B4FFFF
TerrainAtlas: <N> layers at <W>x<H>
loaded 9 landblocks in 3x3 grid
hydrating <several hundred> entities across 9 landblocks
hydrated <several hundred> entities

Expected visual: a 3x3 grid of textured terrain visible in the orbit camera, with buildings populated across all 9 landblocks (not just the center). Terrain should have real textures (not the Phase 1 green/brown/white ramp).

If the app crashes with a GL error or a DatReaderWriter nullref, STOP and debug. Likely culprits:

  • Region.TerrainInfo.LandSurfaces.TexMerge path wrong — inspect region.TerrainInfo at runtime
  • VertexAttribIPointer call using wrong type — check Silk.NET version
  • TerrainTextureType enum cast to uint producing out-of-range values

Kill the process after a few seconds (process stays alive via Silk.NET window loop):

taskkill //IM AcDream.App.exe //F

Step 5: Commit

git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/Rendering/TerrainRenderer.cs src/AcDream.App/Rendering/Shaders/terrain.vert src/AcDream.App/Rendering/Shaders/terrain.frag
git commit -m "feat(app): render 3x3 neighbor landblocks with texture atlas"

Task 5: ICamera interface + OrbitCamera refactor

Files:

  • Create: src/AcDream.App/Rendering/ICamera.cs
  • Modify: src/AcDream.App/Rendering/OrbitCamera.cs
  • Modify: src/AcDream.App/Rendering/TerrainRenderer.cs
  • Modify: src/AcDream.App/Rendering/StaticMeshRenderer.cs
  • Modify: src/AcDream.App/Rendering/GameWindow.cs

No unit tests — interface extraction is behavior-preserving.

Step 1: Create ICamera

// src/AcDream.App/Rendering/ICamera.cs
using System.Numerics;

namespace AcDream.App.Rendering;

public interface ICamera
{
    Matrix4x4 View { get; }
    Matrix4x4 Projection { get; }
    float Aspect { get; set; }
}

Step 2: Make OrbitCamera implement ICamera

In src/AcDream.App/Rendering/OrbitCamera.cs, change the class declaration:

public sealed class OrbitCamera : ICamera

Aspect, View, and Projection properties already match the interface — no other changes needed.

Step 3: Change renderer signatures to ICamera

In src/AcDream.App/Rendering/TerrainRenderer.cs, change Draw(OrbitCamera camera) to Draw(ICamera camera).

In src/AcDream.App/Rendering/StaticMeshRenderer.cs, change Draw(OrbitCamera camera, ...) to Draw(ICamera camera, ...).

Step 4: Build and test

dotnet build
dotnet test

Expected: 0 warnings, 0 errors, 43 tests passing. GameWindow still uses OrbitCamera which now implements ICamera, so call sites compile unchanged.

Step 5: Commit

git add src/AcDream.App/Rendering/ICamera.cs src/AcDream.App/Rendering/OrbitCamera.cs src/AcDream.App/Rendering/TerrainRenderer.cs src/AcDream.App/Rendering/StaticMeshRenderer.cs
git commit -m "feat(app): extract ICamera interface from OrbitCamera"

Task 6: FlyCamera

Files:

  • Create: src/AcDream.App/Rendering/FlyCamera.cs
  • Create: tests/AcDream.Core.Tests/Rendering/FlyCameraTests.cs

Wait — tests/AcDream.Core.Tests/ can't easily reference AcDream.App (the test project references AcDream.Core, not AcDream.App). Move the FlyCamera to AcDream.Core/Rendering/? No — the Phase 1 pattern put OrbitCamera in AcDream.App/Rendering/ because it's GL-adjacent conceptually, but FlyCamera is pure math and can live in Core.

Revised decision for this plan: put FlyCamera, OrbitCamera, and ICamera all in src/AcDream.App/Rendering/, and TDD the FlyCamera math by a small helper test class that's part of the App assembly. Since the App project currently has no tests, create tests/AcDream.App.Tests/ with a new xunit project and reference AcDream.App.

Actually simpler: keep FlyCamera in AcDream.App/Rendering/ and skip formal unit tests — validate via manual smoke in Task 7. Math is simple enough.

Step 1: Create FlyCamera

// src/AcDream.App/Rendering/FlyCamera.cs
using System.Numerics;

namespace AcDream.App.Rendering;

public sealed class FlyCamera : ICamera
{
    public Vector3 Position { get; set; } = new(96, 96, 150);
    public float Yaw { get; set; } = MathF.PI / 2f;  // facing +Y
    public float Pitch { get; set; } = -0.3f;         // looking slightly down
    public float FovY { get; set; } = MathF.PI / 3f;
    public float Aspect { get; set; } = 16f / 9f;

    public float MoveSpeed { get; set; } = 100f;         // world units per second
    public float MouseSensitivity { get; set; } = 0.003f;

    private const float PitchLimit = 1.5533f;  // ~89 degrees

    public Matrix4x4 View
    {
        get
        {
            var forward = Forward();
            return Matrix4x4.CreateLookAt(Position, Position + forward, Vector3.UnitZ);
        }
    }

    public Matrix4x4 Projection
        => Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f);

    /// <summary>
    /// Integrate position for one frame based on WASD + vertical keys.
    /// W/S move forward/back in the horizontal plane (ignoring pitch).
    /// A/D strafe left/right. Up/down translate along world Z.
    /// </summary>
    public void Update(double dt, bool w, bool a, bool s, bool d, bool up, bool down)
    {
        float step = (float)(MoveSpeed * dt);

        // Forward in the horizontal plane (ignore pitch so W doesn't dive into ground).
        var flatForward = new Vector3(MathF.Cos(Yaw), MathF.Sin(Yaw), 0f);
        var right = new Vector3(MathF.Sin(Yaw), -MathF.Cos(Yaw), 0f);

        if (w) Position += flatForward * step;
        if (s) Position -= flatForward * step;
        if (a) Position -= right * step;
        if (d) Position += right * step;
        if (up) Position += Vector3.UnitZ * step;
        if (down) Position -= Vector3.UnitZ * step;
    }

    /// <summary>
    /// Apply accumulated mouse deltas (pixels since last frame). Positive deltaX
    /// rotates the view to the right (decreases yaw), positive deltaY rotates
    /// down (decreases pitch).
    /// </summary>
    public void Look(float deltaX, float deltaY)
    {
        Yaw -= deltaX * MouseSensitivity;
        Pitch = Math.Clamp(Pitch - deltaY * MouseSensitivity, -PitchLimit, PitchLimit);
    }

    private Vector3 Forward()
    {
        float cp = MathF.Cos(Pitch);
        return new Vector3(
            cp * MathF.Cos(Yaw),
            cp * MathF.Sin(Yaw),
            MathF.Sin(Pitch));
    }
}

Step 2: Build

dotnet build

Expected: 0 warnings, 0 errors.

Step 3: Commit

git add src/AcDream.App/Rendering/FlyCamera.cs
git commit -m "feat(app): add FlyCamera with WASD + mouse look"

Task 7: CameraController + input wiring

Files:

  • Create: src/AcDream.App/Rendering/CameraController.cs
  • Modify: src/AcDream.App/Rendering/GameWindow.cs

Step 1: Create CameraController

// src/AcDream.App/Rendering/CameraController.cs
namespace AcDream.App.Rendering;

public sealed class CameraController
{
    public OrbitCamera Orbit { get; }
    public FlyCamera Fly { get; }
    public ICamera Active { get; private set; }
    public bool IsFlyMode => Active == Fly;

    public event Action<bool>? ModeChanged;

    public CameraController(OrbitCamera orbit, FlyCamera fly)
    {
        Orbit = orbit;
        Fly = fly;
        Active = orbit;
    }

    public void ToggleFly()
    {
        Active = IsFlyMode ? (ICamera)Orbit : Fly;
        ModeChanged?.Invoke(IsFlyMode);
    }

    public void SetAspect(float aspect)
    {
        Orbit.Aspect = aspect;
        Fly.Aspect = aspect;
    }
}

Step 2: Wire into GameWindow

In src/AcDream.App/Rendering/GameWindow.cs:

2a. Replace the _camera field with a _cameraController field:

private CameraController? _cameraController;

Remove the _camera field. Add:

private IMouse? _capturedMouse;  // set when entering fly mode

2b. In OnLoad, after the existing _camera = new OrbitCamera { ... } line (which you're deleting), add:

var orbit = new OrbitCamera { Aspect = _window!.Size.X / (float)_window.Size.Y };
var fly = new FlyCamera { Aspect = _window.Size.X / (float)_window.Size.Y };
_cameraController = new CameraController(orbit, fly);
_cameraController.ModeChanged += OnCameraModeChanged;

2c. Replace all references to _camera in the rest of OnLoad with _cameraController.Orbit (for the initial orbit setup) or _cameraController.Active (for renderers that take ICamera). Specifically:

  • _terrain = new TerrainRenderer(_gl, _shader, terrainAtlas); — unchanged
  • _staticMesh = new StaticMeshRenderer(_gl, _meshShader, _textureCache); — unchanged
  • Any spot that read _camera.Yaw, _camera.Pitch, _camera.Distance — those only existed in mouse drag handlers which need splitting (see 2d).

2d. Replace the existing mouse handlers:

foreach (var mouse in _input.Mice)
{
    mouse.MouseMove += (m, pos) =>
    {
        if (_cameraController is null) return;

        if (_cameraController.IsFlyMode)
        {
            // Raw cursor mode: Silk.NET gives deltas via position. Compute delta from last.
            float dx = pos.X - _lastMouseX;
            float dy = pos.Y - _lastMouseY;
            _cameraController.Fly.Look(dx, dy);
        }
        else
        {
            if (m.IsButtonPressed(MouseButton.Left))
            {
                _cameraController.Orbit.Yaw -= (pos.X - _lastMouseX) * 0.005f;
                _cameraController.Orbit.Pitch = Math.Clamp(
                    _cameraController.Orbit.Pitch + (pos.Y - _lastMouseY) * 0.005f,
                    0.1f, 1.5f);
            }
        }
        _lastMouseX = pos.X;
        _lastMouseY = pos.Y;
    };
    mouse.Scroll += (_, scroll) =>
    {
        if (_cameraController is null || _cameraController.IsFlyMode) return;
        _cameraController.Orbit.Distance = Math.Clamp(
            _cameraController.Orbit.Distance - scroll.Y * 20f, 50f, 2000f);
    };
}

2e. Update the keyboard handler to add F toggle and contextual Escape:

foreach (var kb in _input.Keyboards)
    kb.KeyDown += (_, key, _) =>
    {
        if (key == Key.F)
            _cameraController?.ToggleFly();
        else if (key == Key.Escape)
        {
            if (_cameraController?.IsFlyMode == true)
                _cameraController.ToggleFly();  // exit fly, release cursor
            else
                _window!.Close();
        }
    };

2f. Add OnUpdate handler. Register it in Run():

_window.Update += OnUpdate;

(next to the existing _window.Load += OnLoad; etc.)

Then add the method:

private void OnUpdate(double dt)
{
    if (_cameraController is null || _input is null) return;
    if (!_cameraController.IsFlyMode) return;

    var kb = _input.Keyboards[0];
    _cameraController.Fly.Update(
        dt,
        w: kb.IsKeyPressed(Key.W),
        a: kb.IsKeyPressed(Key.A),
        s: kb.IsKeyPressed(Key.S),
        d: kb.IsKeyPressed(Key.D),
        up: kb.IsKeyPressed(Key.Space),
        down: kb.IsKeyPressed(Key.ControlLeft));
}

2g. Add the mode-change handler that manages cursor capture:

private void OnCameraModeChanged(bool isFlyMode)
{
    if (_input is null) return;
    var mouse = _input.Mice.FirstOrDefault();
    if (mouse is null) return;

    mouse.Cursor.CursorMode = isFlyMode ? CursorMode.Raw : CursorMode.Normal;
    _capturedMouse = isFlyMode ? mouse : null;
}

2h. Update OnRender to pass the active camera:

private void OnRender(double deltaSeconds)
{
    _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
    if (_cameraController is not null)
    {
        _terrain?.Draw(_cameraController.Active);
        _staticMesh?.Draw(_cameraController.Active, _entities);
    }
}

Step 3: Build and manual smoke

dotnet build

Expected: 0 warnings, 0 errors.

dotnet run --project src/AcDream.App -- "references/Asheron's Call"

Expected: app opens normally in orbit mode. Pressing F should flip to fly mode (cursor captures, WASD moves the camera). Pressing F again or Escape returns to orbit. A second Escape closes the window.

Kill after quick manual check:

taskkill //IM AcDream.App.exe //F 2>&1

Step 4: Commit

git add src/AcDream.App/Rendering/CameraController.cs src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(app): add CameraController with F toggle and cursor capture"

Task 8: Plugin abstractions + host impls + WorldEvents TDD

Files:

  • Create: src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs
  • Create: src/AcDream.Plugin.Abstractions/IGameState.cs
  • Create: src/AcDream.Plugin.Abstractions/IEvents.cs
  • Modify: src/AcDream.Plugin.Abstractions/IPluginHost.cs
  • Create: src/AcDream.App/Plugins/WorldGameState.cs
  • Create: src/AcDream.App/Plugins/WorldEvents.cs
  • Modify: src/AcDream.App/Plugins/AppPluginHost.cs
  • Create: tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs — wait, AcDream.Core.Tests references AcDream.Core, not AcDream.App. Need to either move WorldEvents to AcDream.Core/Plugins/ (hosting code in Core is weird) OR create a new test project for the App.

Decision: Move WorldEvents to src/AcDream.Core/Plugins/WorldEvents.cs. It has no GL dependency. Move WorldGameState too. Only AppPluginHost stays in the App project.

  • Create: src/AcDream.Core/Plugins/WorldEvents.cs
  • Create: src/AcDream.Core/Plugins/WorldGameState.cs
  • Create: tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs

The AcDream.Core project already references AcDream.Plugin.Abstractions (Phase 1 established this).

Step 1: Create abstractions

// src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs
using System.Numerics;

namespace AcDream.Plugin.Abstractions;

public readonly record struct WorldEntitySnapshot(
    uint Id,
    uint SourceId,
    Vector3 Position,
    Quaternion Rotation);
// src/AcDream.Plugin.Abstractions/IGameState.cs
namespace AcDream.Plugin.Abstractions;

public interface IGameState
{
    IReadOnlyList<WorldEntitySnapshot> Entities { get; }
}
// src/AcDream.Plugin.Abstractions/IEvents.cs
namespace AcDream.Plugin.Abstractions;

public interface IEvents
{
    event Action<WorldEntitySnapshot> EntitySpawned;
}

Step 2: Update IPluginHost

Replace src/AcDream.Plugin.Abstractions/IPluginHost.cs:

namespace AcDream.Plugin.Abstractions;

/// <summary>
/// Entry point for a plugin into the acdream runtime. The surface will grow
/// across phases as more systems come online.
/// </summary>
public interface IPluginHost
{
    IPluginLogger Log { get; }
    IGameState State { get; }
    IEvents Events { get; }
}

Step 3: Write failing tests for WorldEvents

// tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs
using System.Numerics;
using AcDream.Core.Plugins;
using AcDream.Plugin.Abstractions;

namespace AcDream.Core.Tests.Plugins;

public class WorldEventsTests
{
    private static WorldEntitySnapshot S(uint id) => new(id, SourceId: 0x01000000u, Position: Vector3.Zero, Rotation: Quaternion.Identity);

    [Fact]
    public void FireBeforeAnySubscriber_LateSubscribeReceivesReplay()
    {
        var events = new WorldEvents();
        events.FireEntitySpawned(S(1));
        events.FireEntitySpawned(S(2));
        events.FireEntitySpawned(S(3));

        var seen = new List<uint>();
        events.EntitySpawned += e => seen.Add(e.Id);

        Assert.Equal(new uint[] { 1, 2, 3 }, seen);
    }

    [Fact]
    public void FireAfterSubscribe_ReachesSubscriber()
    {
        var events = new WorldEvents();
        var seen = new List<uint>();
        events.EntitySpawned += e => seen.Add(e.Id);

        events.FireEntitySpawned(S(10));
        events.FireEntitySpawned(S(20));

        Assert.Equal(new uint[] { 10, 20 }, seen);
    }

    [Fact]
    public void ReplayPlusLive_DeliversExactlyOnceEach()
    {
        var events = new WorldEvents();
        events.FireEntitySpawned(S(1));  // pre-subscribe

        var seen = new List<uint>();
        events.EntitySpawned += e => seen.Add(e.Id);  // replay fires 1

        events.FireEntitySpawned(S(2));  // live fires 2

        Assert.Equal(new uint[] { 1, 2 }, seen);
    }

    [Fact]
    public void Unsubscribe_StopsLiveDelivery()
    {
        var events = new WorldEvents();
        var seen = new List<uint>();
        Action<WorldEntitySnapshot> handler = e => seen.Add(e.Id);

        events.EntitySpawned += handler;
        events.FireEntitySpawned(S(1));
        events.EntitySpawned -= handler;
        events.FireEntitySpawned(S(2));

        Assert.Equal(new uint[] { 1 }, seen);
    }

    [Fact]
    public void HandlerThrowsDuringReplay_OtherReplayEntriesStillDelivered()
    {
        var events = new WorldEvents();
        events.FireEntitySpawned(S(1));
        events.FireEntitySpawned(S(2));
        events.FireEntitySpawned(S(3));

        var seen = new List<uint>();
        events.EntitySpawned += e =>
        {
            if (e.Id == 2) throw new InvalidOperationException("boom");
            seen.Add(e.Id);
        };

        // No exception propagates out of the += add; 1 and 3 were still delivered.
        Assert.Contains(1u, seen);
        Assert.Contains(3u, seen);
    }
}

Step 4: Run RED

dotnet test --filter "FullyQualifiedName~WorldEventsTests"

Expected: compile errors for WorldEvents not existing.

Step 5: Implement WorldEvents

// src/AcDream.Core/Plugins/WorldEvents.cs
using AcDream.Plugin.Abstractions;

namespace AcDream.Core.Plugins;

public sealed class WorldEvents : IEvents
{
    private readonly object _lock = new();
    private readonly List<WorldEntitySnapshot> _alreadySpawned = new();
    private Action<WorldEntitySnapshot>? _subscribers;

    /// <summary>
    /// Called by the host as each entity is hydrated into the world. Records the
    /// snapshot for later replay and dispatches to current subscribers.
    /// </summary>
    public void FireEntitySpawned(WorldEntitySnapshot snapshot)
    {
        Action<WorldEntitySnapshot>? toNotify;
        lock (_lock)
        {
            _alreadySpawned.Add(snapshot);
            toNotify = _subscribers;
        }

        if (toNotify is null) return;
        foreach (Action<WorldEntitySnapshot> handler in toNotify.GetInvocationList())
        {
            try { handler(snapshot); }
            catch { /* plugin errors don't propagate out of event dispatch */ }
        }
    }

    public event Action<WorldEntitySnapshot> EntitySpawned
    {
        add
        {
            WorldEntitySnapshot[] replay;
            lock (_lock)
            {
                _subscribers += value;
                replay = _alreadySpawned.ToArray();
            }
            // Replay outside the lock to avoid deadlock if a handler re-enters.
            foreach (var s in replay)
            {
                try { value(s); }
                catch { /* plugin errors don't propagate out of += */ }
            }
        }
        remove
        {
            lock (_lock)
                _subscribers -= value;
        }
    }
}

Step 6: Implement WorldGameState

// src/AcDream.Core/Plugins/WorldGameState.cs
using AcDream.Plugin.Abstractions;

namespace AcDream.Core.Plugins;

public sealed class WorldGameState : IGameState
{
    private readonly List<WorldEntitySnapshot> _entities = new();

    public IReadOnlyList<WorldEntitySnapshot> Entities => _entities;

    /// <summary>Called by the host as each entity is hydrated.</summary>
    public void Add(WorldEntitySnapshot snapshot) => _entities.Add(snapshot);
}

Step 7: Update AppPluginHost

Replace src/AcDream.App/Plugins/AppPluginHost.cs:

using AcDream.Plugin.Abstractions;

namespace AcDream.App.Plugins;

public sealed class AppPluginHost : IPluginHost
{
    public AppPluginHost(IPluginLogger log, IGameState state, IEvents events)
    {
        Log = log;
        State = state;
        Events = events;
    }

    public IPluginLogger Log { get; }
    public IGameState State { get; }
    public IEvents Events { get; }
}

Step 8: Update PluginLoaderTests StubHost

The existing PluginLoaderTests has a private StubHost : IPluginHost that only implemented Log. It now needs State and Events too. In tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs:

private sealed class StubHost : IPluginHost
{
    public IPluginLogger Log { get; } = new StubLogger();
    public IGameState State { get; } = new StubState();
    public IEvents Events { get; } = new StubEvents();
}

private sealed class StubLogger : IPluginLogger
{
    public void Info(string message) { }
    public void Warn(string message) { }
    public void Error(string message, Exception? exception = null) { }
}

private sealed class StubState : IGameState
{
    public IReadOnlyList<WorldEntitySnapshot> Entities { get; } = Array.Empty<WorldEntitySnapshot>();
}

private sealed class StubEvents : IEvents
{
    public event Action<WorldEntitySnapshot>? EntitySpawned;
}

(The unused EntitySpawned field may trigger a CS0067 warning. Add #pragma warning disable CS0067 above the class and #pragma warning restore CS0067 after it, OR mark the event as add { } remove { } empty accessors to silence the warning.)

Step 9: Run tests

dotnet test

Expected: Passed: 48 (43 previous + 5 new WorldEvents tests).

Step 10: Commit

git add -A
git commit -m "feat(core): add IGameState, IEvents, WorldEvents with replay-on-subscribe"

Task 9: Program.cs wire + SmokePlugin subscribes

Files:

  • Modify: src/AcDream.App/Program.cs
  • Modify: src/AcDream.App/Rendering/GameWindow.cs
  • Modify: src/AcDream.Plugins.Smoke/SmokePlugin.cs

Step 1: Update Program.cs to build WorldGameState + WorldEvents and pass them to AppPluginHost

In src/AcDream.App/Program.cs, replace the host construction line:

var host = new AppPluginHost(new SerilogAdapter(Log.Logger));

with:

var worldGameState = new AcDream.Core.Plugins.WorldGameState();
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
var host = new AppPluginHost(
    new SerilogAdapter(Log.Logger),
    worldGameState,
    worldEvents);

Step 2: Pass worldGameState and worldEvents into GameWindow

GameWindow constructor currently takes (string datDir). Extend it to:

public GameWindow(
    string datDir,
    AcDream.Core.Plugins.WorldGameState worldGameState,
    AcDream.Core.Plugins.WorldEvents worldEvents)
{
    _datDir = datDir;
    _worldGameState = worldGameState;
    _worldEvents = worldEvents;
}

Add the fields:

private readonly AcDream.Core.Plugins.WorldGameState _worldGameState;
private readonly AcDream.Core.Plugins.WorldEvents _worldEvents;

In Program.cs, update the constructor call:

using var window = new GameWindow(datDir, worldGameState, worldEvents);

Step 3: Fire events during entity hydration in GameWindow.OnLoad

Inside the foreach (var e in allEntities) loop in GameWindow.OnLoad, after the if (meshRefs.Count > 0) { hydratedEntities.Add(...); } block, add:

if (meshRefs.Count > 0)
{
    var hydrated = new AcDream.Core.World.WorldEntity { /* ... existing ... */ };
    hydratedEntities.Add(hydrated);

    // Fire plugin event + update game state snapshot.
    var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
        Id: hydrated.Id,
        SourceId: hydrated.SourceGfxObjOrSetupId,
        Position: hydrated.Position,
        Rotation: hydrated.Rotation);
    _worldGameState.Add(snapshot);
    _worldEvents.FireEntitySpawned(snapshot);
}

(The existing block used hydratedEntities.Add(new AcDream.Core.World.WorldEntity { ... }) inline. Refactor to capture the entity in a local first so we can build the snapshot from it.)

Step 4: Update SmokePlugin to subscribe

Replace src/AcDream.Plugins.Smoke/SmokePlugin.cs:

using AcDream.Plugin.Abstractions;

namespace AcDream.Plugins.Smoke;

public sealed class SmokePlugin : IAcDreamPlugin
{
    private IPluginHost? _host;
    private int _entitiesSeen;

    public void Initialize(IPluginHost host)
    {
        _host = host;
        _host.Log.Info("smoke plugin initialized");
    }

    public void Enable()
    {
        _host!.Log.Info("smoke plugin enabled");
        _host.Events.EntitySpawned += OnEntitySpawned;
        _host.Log.Info($"smoke plugin sees {_entitiesSeen} entities (replay count at subscribe)");
    }

    public void Disable()
    {
        if (_host is not null)
        {
            _host.Events.EntitySpawned -= OnEntitySpawned;
            _host.Log.Info($"smoke plugin disabled (saw {_entitiesSeen} entities total)");
        }
    }

    private void OnEntitySpawned(WorldEntitySnapshot entity) => _entitiesSeen++;
}

Step 5: Build and test

dotnet build
dotnet test

Expected: clean build, 48 tests passing.

Step 6: End-to-end smoke

dotnet run --project src/AcDream.App -- "references/Asheron's Call"

Expected console output:

[INF] scanning plugins in ...\plugins
[INF] smoke plugin initialized
[INF] loaded plugin acdream.smoke (Smoke Plugin)
[INF] smoke plugin enabled
[INF] smoke plugin sees 0 entities (replay count at subscribe)
loading world view centered on 0xA9B4FFFF
TerrainAtlas: <N> layers at <W>x<H>
loaded 9 landblocks in 3x3 grid
hydrating <several hundred> entities across 9 landblocks
hydrated <several hundred> entities
(window opens; user can orbit + F-toggle to fly mode)
(user closes window)
[INF] smoke plugin disabled (saw <several hundred> entities total)

After ~8 seconds of run, kill:

taskkill //IM AcDream.App.exe //F 2>&1

Verify the output file shows both the sees 0 entities line AND the saw <N> entities total line (where N matches the hydrated count). This proves the replay-on-subscribe path correctly delivers live events to a subscriber registered before the world loaded.

Step 7: Commit

git add -A
git commit -m "feat(app): wire IGameState+IEvents into Program and SmokePlugin"

Phase 2b done criteria

  1. dotnet build — clean, 0 warnings.
  2. dotnet test — 48 passing (42 carried + 6 added: 1 LandblockMesh, 5 WorldEvents).
  3. dotnet run --project src/AcDream.App -- "references/Asheron's Call" — opens a window showing Holtburg's 3x3 landblock grid with real terrain textures, populated with static entities across all 9 landblocks. F toggles between orbit and fly camera. Escape in fly mode returns to orbit; Escape in orbit closes.
  4. Console shows both smoke plugin lines (sees 0 entities (replay) at enable time, saw N entities total at disable time, where N > 100).
  5. 10 task commits on phase-2b/atlas-neighbors-cameras-events branch (Task 0 is branch creation only).

Stopping point

Task 9 complete → merge to main → update memory → end of session.