# 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.