diff --git a/docs/plans/2026-04-10-phase-2b-plan.md b/docs/plans/2026-04-10-phase-2b-plan.md new file mode 100644 index 0000000..7c0995c --- /dev/null +++ b/docs/plans/2026-04-10-phase-2b-plan.md @@ -0,0 +1,1648 @@ +# 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: +```bash +git checkout main +git status +``` +Expected: `On branch main`, `nothing to commit, working tree clean`. + +- [ ] **Step 2:** Create feature branch: +```bash +git checkout -b phase-2b/atlas-neighbors-cameras-events +``` + +- [ ] **Step 3:** Sanity build + test: +```bash +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`: + +```csharp +[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 + { + [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 + +```bash +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: + +```csharp +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: + +```csharp +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 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: + +```csharp +LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap) +``` + +Add a static field near `IdentityHeightTable`: + +```csharp +private static readonly IReadOnlyDictionary EmptyTerrainMap = + new Dictionary(); +``` + +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: + +```csharp +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: + +```csharp +_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 + +```bash +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 + +```bash +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 + +```bash +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`: + +```csharp +using AcDream.Core.Textures; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// 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))). +/// +public sealed unsafe class TerrainAtlas : IDisposable +{ + private readonly GL _gl; + public uint GlTexture { get; } + public IReadOnlyDictionary TerrainTypeToLayer { get; } + public int LayerCount { get; } + + private TerrainAtlas(GL gl, uint glTexture, IReadOnlyDictionary map, int layerCount) + { + _gl = gl; + GlTexture = glTexture; + TerrainTypeToLayer = map; + LayerCount = layerCount; + } + + /// + /// 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. + /// + public static TerrainAtlas Build(GL gl, DatCollection dats) + { + var region = dats.Get(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 TextureId. Decode + // each referenced SurfaceTexture → RenderSurface → RGBA8 via SurfaceDecoder. + var decodedByType = new Dictionary(); + 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(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((uint)st.Textures[0]); + if (rs is null) + { + decodedByType[typeKey] = DecodedTexture.Magenta; + continue; + } + + Palette? palette = rs.DefaultPaletteId != 0 + ? dats.Get(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(); + 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 { [0] = 0u }, 1); + } + + public void Dispose() => _gl.DeleteTexture(GlTexture); +} +``` + +### Step 2: Build + +```bash +dotnet build +``` +Expected: 0 warnings, 0 errors. This is compile-only verification; runtime exercise is Task 4. + +### Step 3: Commit + +```bash +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`: + +```glsl +#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`: + +```glsl +#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`: + +```csharp +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 _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 + +```bash +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: + +```csharp +uint centerLandblockId = 0xA9B4FFFFu; +Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}"); + +var region = _dats.Get(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(...); var entities = info is not null ? LandblockLoader.BuildEntitiesFromInfo(info) : ...` and replace with: + +```csharp +_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(allEntities.Count); +foreach (var e in allEntities) +{ + var meshRefs = new List(); + + if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u) + { + var gfx = _dats.Get(e.SourceGfxObjOrSetupId); + if (gfx is not null) + { + var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx); + _staticMesh.EnsureUploaded(e.SourceGfxObjOrSetupId, subMeshes); + meshRefs.Add(new AcDream.Core.World.MeshRef( + e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity)); + } + } + else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) + { + var setup = _dats.Get(e.SourceGfxObjOrSetupId); + if (setup is not null) + { + var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); + foreach (var mr in flat) + { + var gfx = _dats.Get(mr.GfxObjId); + if (gfx is null) continue; + var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx); + _staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes); + meshRefs.Add(mr); + } + } + } + + if (meshRefs.Count > 0) + { + // 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 + +```bash +dotnet build +``` +Expected: 0 warnings, 0 errors. + +### Step 4: Run against real dats (manual smoke) + +```bash +dotnet run --project src/AcDream.App -- "references/Asheron's Call" +``` + +Expected console output: +``` +loading world view centered on 0xA9B4FFFF +TerrainAtlas: layers at x +loaded 9 landblocks in 3x3 grid +hydrating entities across 9 landblocks +hydrated 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): +```bash +taskkill //IM AcDream.App.exe //F +``` + +### Step 5: Commit + +```bash +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` + +```csharp +// 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: + +```csharp +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 + +```bash +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 + +```bash +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` + +```csharp +// 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); + + /// + /// 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. + /// + 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; + } + + /// + /// Apply accumulated mouse deltas (pixels since last frame). Positive deltaX + /// rotates the view to the right (decreases yaw), positive deltaY rotates + /// down (decreases pitch). + /// + 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 + +```bash +dotnet build +``` +Expected: 0 warnings, 0 errors. + +### Step 3: Commit + +```bash +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` + +```csharp +// 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? 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: + +```csharp +private CameraController? _cameraController; +``` + +Remove the `_camera` field. Add: + +```csharp +private IMouse? _capturedMouse; // set when entering fly mode +``` + +**2b.** In `OnLoad`, after the existing `_camera = new OrbitCamera { ... }` line (which you're deleting), add: + +```csharp +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: + +```csharp +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: + +```csharp +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()`: + +```csharp +_window.Update += OnUpdate; +``` + +(next to the existing `_window.Load += OnLoad;` etc.) + +Then add the method: + +```csharp +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: + +```csharp +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: + +```csharp +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 + +```bash +dotnet build +``` +Expected: 0 warnings, 0 errors. + +```bash +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: +```bash +taskkill //IM AcDream.App.exe //F 2>&1 +``` + +### Step 4: Commit + +```bash +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 + +```csharp +// 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); +``` + +```csharp +// src/AcDream.Plugin.Abstractions/IGameState.cs +namespace AcDream.Plugin.Abstractions; + +public interface IGameState +{ + IReadOnlyList Entities { get; } +} +``` + +```csharp +// src/AcDream.Plugin.Abstractions/IEvents.cs +namespace AcDream.Plugin.Abstractions; + +public interface IEvents +{ + event Action EntitySpawned; +} +``` + +### Step 2: Update `IPluginHost` + +Replace `src/AcDream.Plugin.Abstractions/IPluginHost.cs`: + +```csharp +namespace AcDream.Plugin.Abstractions; + +/// +/// Entry point for a plugin into the acdream runtime. The surface will grow +/// across phases as more systems come online. +/// +public interface IPluginHost +{ + IPluginLogger Log { get; } + IGameState State { get; } + IEvents Events { get; } +} +``` + +### Step 3: Write failing tests for `WorldEvents` + +```csharp +// 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(); + 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(); + 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(); + 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(); + Action 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(); + 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 + +```bash +dotnet test --filter "FullyQualifiedName~WorldEventsTests" +``` +Expected: compile errors for `WorldEvents` not existing. + +### Step 5: Implement `WorldEvents` + +```csharp +// 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 _alreadySpawned = new(); + private Action? _subscribers; + + /// + /// Called by the host as each entity is hydrated into the world. Records the + /// snapshot for later replay and dispatches to current subscribers. + /// + public void FireEntitySpawned(WorldEntitySnapshot snapshot) + { + Action? toNotify; + lock (_lock) + { + _alreadySpawned.Add(snapshot); + toNotify = _subscribers; + } + + if (toNotify is null) return; + foreach (Action handler in toNotify.GetInvocationList()) + { + try { handler(snapshot); } + catch { /* plugin errors don't propagate out of event dispatch */ } + } + } + + public event Action 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` + +```csharp +// src/AcDream.Core/Plugins/WorldGameState.cs +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +public sealed class WorldGameState : IGameState +{ + private readonly List _entities = new(); + + public IReadOnlyList Entities => _entities; + + /// Called by the host as each entity is hydrated. + public void Add(WorldEntitySnapshot snapshot) => _entities.Add(snapshot); +} +``` + +### Step 7: Update `AppPluginHost` + +Replace `src/AcDream.App/Plugins/AppPluginHost.cs`: + +```csharp +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`: + +```csharp +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 Entities { get; } = Array.Empty(); +} + +private sealed class StubEvents : IEvents +{ + public event Action? 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 + +```bash +dotnet test +``` +Expected: `Passed: 48` (43 previous + 5 new WorldEvents tests). + +### Step 10: Commit + +```bash +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: + +```csharp +var host = new AppPluginHost(new SerilogAdapter(Log.Logger)); +``` + +with: + +```csharp +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: + +```csharp +public GameWindow( + string datDir, + AcDream.Core.Plugins.WorldGameState worldGameState, + AcDream.Core.Plugins.WorldEvents worldEvents) +{ + _datDir = datDir; + _worldGameState = worldGameState; + _worldEvents = worldEvents; +} +``` + +Add the fields: + +```csharp +private readonly AcDream.Core.Plugins.WorldGameState _worldGameState; +private readonly AcDream.Core.Plugins.WorldEvents _worldEvents; +``` + +In `Program.cs`, update the constructor call: + +```csharp +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: + +```csharp +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`: + +```csharp +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 + +```bash +dotnet build +dotnet test +``` +Expected: clean build, 48 tests passing. + +### Step 6: End-to-end smoke + +```bash +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: layers at x +loaded 9 landblocks in 3x3 grid +hydrating entities across 9 landblocks +hydrated entities +(window opens; user can orbit + F-toggle to fly mode) +(user closes window) +[INF] smoke plugin disabled (saw entities total) +``` + +After ~8 seconds of run, kill: +```bash +taskkill //IM AcDream.App.exe //F 2>&1 +``` + +Verify the output file shows both the `sees 0 entities` line AND the `saw 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 + +```bash +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.