From 324abed6eb60de5d1aed2e84c87a6442cbd1a749 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Apr 2026 20:16:25 +0200 Subject: [PATCH 1/9] feat(core): add Vertex.TerrainLayer + LandblockMesh layer map Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/Rendering/GameWindow.cs | 2 +- .../Rendering/StaticMeshRenderer.cs | 2 + src/AcDream.App/Rendering/TerrainRenderer.cs | 2 + src/AcDream.Core/Meshing/GfxObjMesh.cs | 2 +- src/AcDream.Core/Terrain/LandblockMesh.cs | 42 +++++++++---------- src/AcDream.Core/Terrain/Vertex.cs | 6 ++- .../Terrain/LandblockMeshTests.cs | 37 +++++++++++++--- 7 files changed, 64 insertions(+), 29 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index f9c742d..b40a392 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -133,7 +133,7 @@ public sealed class GameWindow : IDisposable if (heightTable is null || heightTable.Length < 256) throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated"); - var meshData = LandblockMesh.Build(block, heightTable); + var meshData = LandblockMesh.Build(block, heightTable, new Dictionary()); _terrain = new TerrainRenderer(_gl, meshData, _shader); _textureCache = new TextureCache(_gl, _dats); diff --git a/src/AcDream.App/Rendering/StaticMeshRenderer.cs b/src/AcDream.App/Rendering/StaticMeshRenderer.cs index 621cc31..12b7a8b 100644 --- a/src/AcDream.App/Rendering/StaticMeshRenderer.cs +++ b/src/AcDream.App/Rendering/StaticMeshRenderer.cs @@ -58,6 +58,8 @@ public sealed unsafe class StaticMeshRenderer : IDisposable _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); diff --git a/src/AcDream.App/Rendering/TerrainRenderer.cs b/src/AcDream.App/Rendering/TerrainRenderer.cs index e61c301..40ba538 100644 --- a/src/AcDream.App/Rendering/TerrainRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainRenderer.cs @@ -41,6 +41,8 @@ public sealed unsafe class TerrainRenderer : IDisposable _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); } diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs index 31e6309..2e5bf00 100644 --- a/src/AcDream.Core/Meshing/GfxObjMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs @@ -55,7 +55,7 @@ public static class GfxObjMesh if (!bucket.Dedupe.TryGetValue(key, out var outIdx)) { outIdx = (uint)bucket.Vertices.Count; - bucket.Vertices.Add(new Vertex(sw.Origin, sw.Normal, texcoord)); + bucket.Vertices.Add(new Vertex(sw.Origin, sw.Normal, texcoord, TerrainLayer: 0)); bucket.Dedupe[key] = outIdx; } polyOut.Add(outIdx); diff --git a/src/AcDream.Core/Terrain/LandblockMesh.cs b/src/AcDream.Core/Terrain/LandblockMesh.cs index 9c3c100..934e754 100644 --- a/src/AcDream.Core/Terrain/LandblockMesh.cs +++ b/src/AcDream.Core/Terrain/LandblockMesh.cs @@ -7,19 +7,20 @@ public sealed record LandblockMeshData(Vertex[] Vertices, uint[] Indices); public static class LandblockMesh { - // AC landblock geometry constants - private const int VerticesPerSide = 9; // 9x9 heightmap grid - private const int CellsPerSide = VerticesPerSide - 1; // 8x8 cells - private const float CellSize = 24.0f; // world units per cell edge + 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; - /// - /// Build the CPU mesh for one landblock's heightmap. - /// is the 256-entry non-linear height lookup from Region.LandDefs.LandHeightTable — - /// AC encodes per-vertex heights as indices into this table, not raw world-Z. - /// - public static LandblockMeshData Build(LandBlock block, float[] heightTable) + 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)); @@ -28,23 +29,23 @@ public static class LandblockMesh { for (int x = 0; x < VerticesPerSide; x++) { - // Vertex buffer index (row-major, y*9+x) is internal to this mesh - // and what the index buffer below references. int vi = y * VerticesPerSide + x; - - // Height dat index is PACKED AS x*9+y — AC stores per-vertex - // heights in x-major order (see ACViewer's - // LandblockStruct: Height[x * VertexDim + y]). Using y*9+x here - // (as Phase 1 did) transposes the terrain along its diagonal, - // which is invisible for flat landblocks but leaves buildings - // buried by ~10+ units on real terrain like Holtburg. int hi = x * VerticesPerSide + y; float height = heightTable[block.Height[hi]]; + + // TerrainInfo raw ushort value used as the atlas-layer map key. + // The map is keyed on the raw terrain ushort (which encodes Road, + // Type, and Scenery fields), matching what the test and caller supply. + uint terrainType = (ushort)block.Terrain[hi]; + 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 / (float)CellsPerSide, y / (float)CellsPerSide)); + TexCoord: new Vector2(x / TexCoordDivisor, y / TexCoordDivisor), + TerrainLayer: layer); } } @@ -58,7 +59,6 @@ public static class LandblockMesh uint b = (uint)(y * VerticesPerSide + x + 1); uint c = (uint)((y + 1) * VerticesPerSide + x); uint d = (uint)((y + 1) * VerticesPerSide + x + 1); - // two triangles per cell, CCW indices[idx++] = a; indices[idx++] = b; indices[idx++] = d; indices[idx++] = a; indices[idx++] = d; indices[idx++] = c; } diff --git a/src/AcDream.Core/Terrain/Vertex.cs b/src/AcDream.Core/Terrain/Vertex.cs index b590ef2..d948d78 100644 --- a/src/AcDream.Core/Terrain/Vertex.cs +++ b/src/AcDream.Core/Terrain/Vertex.cs @@ -2,4 +2,8 @@ using System.Numerics; namespace AcDream.Core.Terrain; -public readonly record struct Vertex(Vector3 Position, Vector3 Normal, Vector2 TexCoord); +public readonly record struct Vertex( + Vector3 Position, + Vector3 Normal, + Vector2 TexCoord, + uint TerrainLayer); diff --git a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs index b2853f6..d106fa2 100644 --- a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs +++ b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs @@ -15,6 +15,9 @@ public class LandblockMeshTests private static readonly float[] IdentityHeightTable = Enumerable.Range(0, 256).Select(i => i * 2f).ToArray(); + private static readonly IReadOnlyDictionary EmptyTerrainMap = + new Dictionary(); + private static LandBlock BuildFlatLandBlock(byte heightIndex = 0) { var block = new LandBlock @@ -36,7 +39,7 @@ public class LandblockMeshTests { var block = BuildFlatLandBlock(); - var mesh = LandblockMesh.Build(block, IdentityHeightTable); + var mesh = LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap); Assert.Equal(81, mesh.Vertices.Length); Assert.Equal(128 * 3, mesh.Indices.Length); @@ -47,7 +50,7 @@ public class LandblockMeshTests { var block = BuildFlatLandBlock(); - var mesh = LandblockMesh.Build(block, IdentityHeightTable); + var mesh = LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap); var minX = mesh.Vertices.Min(v => v.Position.X); var maxX = mesh.Vertices.Max(v => v.Position.X); @@ -65,7 +68,7 @@ public class LandblockMeshTests { var block = BuildFlatLandBlock(heightIndex: 10); - var mesh = LandblockMesh.Build(block, IdentityHeightTable); + var mesh = LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap); var zs = mesh.Vertices.Select(v => v.Position.Z).Distinct().ToArray(); Assert.Single(zs); @@ -76,12 +79,36 @@ public class LandblockMeshTests { var block = BuildFlatLandBlock(heightIndex: 5); - var mesh = LandblockMesh.Build(block, IdentityHeightTable); + var mesh = LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap); // AC's Land::LandHeightTable scales height byte index by 2.0f for the simple ramp case. Assert.Equal(10.0f, mesh.Vertices[0].Position.Z); } + [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); + } + [Fact] public void Build_HeightmapPackedAsXMajor_NotYMajor() { @@ -98,7 +125,7 @@ public class LandblockMeshTests var block = BuildFlatLandBlock(); block.Height[2 * 9 + 0] = 5; // x=2, y=0 in x-major packing - var mesh = LandblockMesh.Build(block, IdentityHeightTable); + var mesh = LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap); // Find vertices by position. Vertex buffer uses y*9+x internally. var vAt_x2_y0 = mesh.Vertices[0 * 9 + 2]; // world (48, 0) From 78ce09944039499135670a5b51629b62d0fe58d3 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Apr 2026 20:18:09 +0200 Subject: [PATCH 2/9] fix(core): LandblockMesh keys atlas lookup on TerrainInfo.Type Task 1's subagent used the raw ushort as the map key because the test used raw ushort 7 as the value. But the atlas map is built from Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc which keys on TerrainTextureType enum values, extracted from bits 2-6 of the TerrainInfo ushort per DatReaderWriter's Types/TerrainInfo.cs. Reverts to using block.Terrain[hi].Type so the Task 2 TerrainAtlas can actually find matching keys against real dat terrain. The test is updated to encode Type=7 correctly as (7 << 2) in the raw ushort. --- src/AcDream.Core/Terrain/LandblockMesh.cs | 9 +++++---- .../Terrain/LandblockMeshTests.cs | 14 ++++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/AcDream.Core/Terrain/LandblockMesh.cs b/src/AcDream.Core/Terrain/LandblockMesh.cs index 934e754..860353e 100644 --- a/src/AcDream.Core/Terrain/LandblockMesh.cs +++ b/src/AcDream.Core/Terrain/LandblockMesh.cs @@ -34,10 +34,11 @@ public static class LandblockMesh float height = heightTable[block.Height[hi]]; - // TerrainInfo raw ushort value used as the atlas-layer map key. - // The map is keyed on the raw terrain ushort (which encodes Road, - // Type, and Scenery fields), matching what the test and caller supply. - uint terrainType = (ushort)block.Terrain[hi]; + // TerrainInfo is bit-packed: bits 0-1 Road, bits 2-6 Type (5-bit + // TerrainTextureType enum), bits 11-15 Scenery. The atlas keys on + // Type only, matching Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc + // which lists SurfaceTexture ids per TerrainTextureType. + uint terrainType = (uint)block.Terrain[hi].Type; if (!terrainTypeToLayer.TryGetValue(terrainType, out var layer)) layer = 0; diff --git a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs index d106fa2..0d91e6e 100644 --- a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs +++ b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs @@ -89,15 +89,17 @@ public class LandblockMeshTests 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 + // TerrainInfo is bit-packed: bits 0-1 Road, bits 2-6 Type, bits 11-15 Scenery. + // Raw ushort 0x001C = binary 0011100 → Type field = 7 (bits 2-6). + // This is what a terrain sample with TerrainTextureType=7 looks like in the + // underlying byte stream. LandblockMesh uses TerrainInfo.Type (not raw) as + // the atlas lookup key. + block.Terrain[2 * 9 + 3] = (ushort)(7 << 2); // Type=7, Road=0, Scenery=0 var map = new Dictionary { [0] = 0u, // default type → atlas layer 0 - [7] = 4u, // type 7 → atlas layer 4 + [7] = 4u, // TerrainTextureType 7 → atlas layer 4 }; var mesh = LandblockMesh.Build(block, IdentityHeightTable, map); @@ -105,7 +107,7 @@ public class LandblockMeshTests // 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. + // An untouched vertex still has Type 0, maps to layer 0. Assert.Equal(0u, mesh.Vertices[0].TerrainLayer); } From 347a7e92ffbec7e6221a279c82f8a13e760191c0 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Apr 2026 20:19:36 +0200 Subject: [PATCH 3/9] feat(app): add TerrainAtlas for GL_TEXTURE_2D_ARRAY terrain textures --- src/AcDream.App/Rendering/TerrainAtlas.cs | 164 ++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 src/AcDream.App/Rendering/TerrainAtlas.cs diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs new file mode 100644 index 0000000..470231b --- /dev/null +++ b/src/AcDream.App/Rendering/TerrainAtlas.cs @@ -0,0 +1,164 @@ +using AcDream.Core.Textures; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using Silk.NET.OpenGL; +using DatPixelFormat = DatReaderWriter.Enums.PixelFormat; +using GLPixelFormat = Silk.NET.OpenGL.PixelFormat; + +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, GLPixelFormat.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, + GLPixelFormat.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, GLPixelFormat.Rgba, PixelType.UnsignedByte, null); + fixed (byte* p = white) + gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.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); +} From 560100e5b6ec613169cb370ee7ae1385a21cbbfd Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Apr 2026 20:23:21 +0200 Subject: [PATCH 4/9] feat(app): render 3x3 neighbor landblocks with texture atlas Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/Rendering/GameWindow.cs | 96 +++++++++---------- .../Rendering/Shaders/terrain.frag | 14 +-- .../Rendering/Shaders/terrain.vert | 10 +- src/AcDream.App/Rendering/TerrainRenderer.cs | 68 +++++++++---- 4 files changed, 109 insertions(+), 79 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b40a392..3c63255 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1,6 +1,4 @@ -using AcDream.Core.Terrain; using DatReaderWriter; -using DatReaderWriter.DBObjs; using DatReaderWriter.Options; using Silk.NET.Input; using Silk.NET.Maths; @@ -98,75 +96,67 @@ public sealed class GameWindow : IDisposable _dats = new DatCollection(_datDir, DatAccessType.Read); - // Find ANY landblock ending in 0xFFFF. Holtburg 0xA9B4FFFF is a - // good default; fall back to the first one we find. Using Get - // (returns null on miss) rather than TryGet to sidestep - // [MaybeNullWhen(false)] nullable-generic analysis under - // TreatWarningsAsErrors. - uint landblockId = 0xA9B4FFFFu; - var block = _dats.Get(landblockId); - if (block is null) - { - foreach (var file in _dats.Cell.Tree) - { - if ((file.Id & 0xFFFFu) == 0xFFFFu) - { - landblockId = file.Id; - block = _dats.Get(landblockId); - break; - } - } - } + uint centerLandblockId = 0xA9B4FFFFu; + Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}"); - if (block is null) - throw new InvalidOperationException("no landblock found in cell dat"); - - Console.WriteLine($"loaded landblock 0x{landblockId:X8}"); - - // Load the non-linear LandHeightTable from the Region dat. AC encodes - // per-vertex heights as byte indices into this 256-entry float table, - // not as a simple * 2.0 ramp — building placements depend on the real - // table, so terrain rendered with the simplified scale would leave - // buildings floating or buried. 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"); - var meshData = LandblockMesh.Build(block, heightTable, new Dictionary()); - _terrain = new TerrainRenderer(_gl, meshData, _shader); + // 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); + } _textureCache = new TextureCache(_gl, _dats); _staticMesh = new StaticMeshRenderer(_gl, _meshShader, _textureCache); - // Load LandBlockInfo for Holtburg, hydrate entities. - var info = _dats.Get((landblockId & 0xFFFF0000u) | 0xFFFEu); - var entities = info is not null - ? AcDream.Core.World.LandblockLoader.BuildEntitiesFromInfo(info) - : Array.Empty(); + // 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"); - // Populate MeshRefs for each entity by resolving its source id to GfxObj or Setup - // and extracting sub-meshes. Store back onto the entity. Since WorldEntity is - // `required init`, we rebuild the entity here. - var hydratedEntities = new List(entities.Count); - foreach (var e in entities) + var hydratedEntities = new List(allEntities.Count); + foreach (var e in allEntities) { var meshRefs = new List(); if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u) { - // GfxObj: one mesh ref with identity transform. 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)); + meshRefs.Add(new AcDream.Core.World.MeshRef( + e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity)); } } else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) { - // Setup: flatten into parts, upload each part's GfxObj. var setup = _dats.Get(e.SourceGfxObjOrSetupId); if (setup is not null) { @@ -184,11 +174,21 @@ public sealed class GameWindow : IDisposable 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, + Position = e.Position + worldOffset, Rotation = e.Rotation, MeshRefs = meshRefs, }); @@ -196,7 +196,7 @@ public sealed class GameWindow : IDisposable } _entities = hydratedEntities; - Console.WriteLine($"hydrated {_entities.Count} entities on landblock 0x{landblockId:X8}"); + Console.WriteLine($"hydrated {_entities.Count} entities"); } private void OnRender(double deltaSeconds) diff --git a/src/AcDream.App/Rendering/Shaders/terrain.frag b/src/AcDream.App/Rendering/Shaders/terrain.frag index d6e747b..6e0e917 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain.frag +++ b/src/AcDream.App/Rendering/Shaders/terrain.frag @@ -1,14 +1,10 @@ #version 430 core -in float vHeight; +in vec2 vTex; +in flat uint vLayer; out vec4 fragColor; +uniform sampler2DArray uAtlas; + void main() { - float t = clamp(vHeight / 200.0, 0.0, 1.0); - vec3 low = vec3(0.10, 0.35, 0.15); // green lowland - vec3 mid = vec3(0.55, 0.45, 0.25); // brown mid - vec3 high = vec3(0.90, 0.90, 0.95); // snowy peak - vec3 color = t < 0.5 - ? mix(low, mid, t * 2.0) - : mix(mid, high, (t - 0.5) * 2.0); - fragColor = vec4(color, 1.0); + fragColor = texture(uAtlas, vec3(vTex, float(vLayer))); } diff --git a/src/AcDream.App/Rendering/Shaders/terrain.vert b/src/AcDream.App/Rendering/Shaders/terrain.vert index 1f3b04f..a9443e2 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain.vert +++ b/src/AcDream.App/Rendering/Shaders/terrain.vert @@ -2,13 +2,17 @@ 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 float vHeight; +out vec2 vTex; +out flat uint vLayer; void main() { - vHeight = aPos.z; - gl_Position = uProjection * uView * vec4(aPos, 1.0); + vTex = aTex; + vLayer = aTerrainLayer; + gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0); } diff --git a/src/AcDream.App/Rendering/TerrainRenderer.cs b/src/AcDream.App/Rendering/TerrainRenderer.cs index 40ba538..c82eda0 100644 --- a/src/AcDream.App/Rendering/TerrainRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainRenderer.cs @@ -1,3 +1,4 @@ +using System.Numerics; using AcDream.Core.Terrain; using Silk.NET.OpenGL; @@ -7,33 +8,39 @@ public sealed unsafe class TerrainRenderer : IDisposable { private readonly GL _gl; private readonly Shader _shader; - private readonly uint _vao; - private readonly uint _vbo; - private readonly uint _ebo; - private readonly int _indexCount; + private readonly TerrainAtlas _atlas; + private readonly List _landblocks = new(); - public TerrainRenderer(GL gl, LandblockMeshData meshData, Shader shader) + public TerrainRenderer(GL gl, Shader shader, TerrainAtlas atlas) { _gl = gl; _shader = shader; - _indexCount = meshData.Indices.Length; + _atlas = atlas; + } - _vao = _gl.GenVertexArray(); - _gl.BindVertexArray(_vao); + public void AddLandblock(LandblockMeshData meshData, Vector3 worldOrigin) + { + var gpu = new LandblockGpu + { + Vao = _gl.GenVertexArray(), + WorldOrigin = worldOrigin, + IndexCount = meshData.Indices.Length, + }; - _vbo = _gl.GenBuffer(); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); + _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); - _ebo = _gl.GenBuffer(); - _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _ebo); + 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); - // vertex layout: position(3f), normal(3f), texcoord(2f) = 8 floats stride uint stride = (uint)sizeof(Vertex); _gl.EnableVertexAttribArray(0); _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0); @@ -45,22 +52,45 @@ public sealed unsafe class TerrainRenderer : IDisposable _gl.VertexAttribIPointer(3, 1, VertexAttribIType.UnsignedInt, stride, (void*)(8 * sizeof(float))); _gl.BindVertexArray(0); + _landblocks.Add(gpu); } - public void Draw(OrbitCamera camera) + public void Draw(OrbitCamera camera) // ICamera in Task 5 { _shader.Use(); _shader.SetMatrix4("uView", camera.View); _shader.SetMatrix4("uProjection", camera.Projection); - _gl.BindVertexArray(_vao); - _gl.DrawElements(PrimitiveType.Triangles, (uint)_indexCount, DrawElementsType.UnsignedInt, (void*)0); + + _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() { - _gl.DeleteBuffer(_vbo); - _gl.DeleteBuffer(_ebo); - _gl.DeleteVertexArray(_vao); + 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; } } From 5640c153f3e9e1f6119aaef1b7b1ccb38ec147d4 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Apr 2026 20:24:29 +0200 Subject: [PATCH 5/9] feat(app): extract ICamera interface from OrbitCamera Introduce ICamera (View, Projection, Aspect) and make OrbitCamera implement it. TerrainRenderer.Draw and StaticMeshRenderer.Draw now accept ICamera, widening the call-site contract while leaving all runtime behavior unchanged. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/Rendering/ICamera.cs | 10 ++++++++++ src/AcDream.App/Rendering/OrbitCamera.cs | 2 +- src/AcDream.App/Rendering/StaticMeshRenderer.cs | 2 +- src/AcDream.App/Rendering/TerrainRenderer.cs | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 src/AcDream.App/Rendering/ICamera.cs diff --git a/src/AcDream.App/Rendering/ICamera.cs b/src/AcDream.App/Rendering/ICamera.cs new file mode 100644 index 0000000..3aeaf98 --- /dev/null +++ b/src/AcDream.App/Rendering/ICamera.cs @@ -0,0 +1,10 @@ +using System.Numerics; + +namespace AcDream.App.Rendering; + +public interface ICamera +{ + Matrix4x4 View { get; } + Matrix4x4 Projection { get; } + float Aspect { get; set; } +} diff --git a/src/AcDream.App/Rendering/OrbitCamera.cs b/src/AcDream.App/Rendering/OrbitCamera.cs index 95f9977..43f2c96 100644 --- a/src/AcDream.App/Rendering/OrbitCamera.cs +++ b/src/AcDream.App/Rendering/OrbitCamera.cs @@ -2,7 +2,7 @@ using System.Numerics; namespace AcDream.App.Rendering; -public sealed class OrbitCamera +public sealed class OrbitCamera : ICamera { public Vector3 Target { get; set; } = new(96, 96, 0); // center of a 192x192 landblock public float Distance { get; set; } = 300f; diff --git a/src/AcDream.App/Rendering/StaticMeshRenderer.cs b/src/AcDream.App/Rendering/StaticMeshRenderer.cs index 12b7a8b..424eb8b 100644 --- a/src/AcDream.App/Rendering/StaticMeshRenderer.cs +++ b/src/AcDream.App/Rendering/StaticMeshRenderer.cs @@ -73,7 +73,7 @@ public sealed unsafe class StaticMeshRenderer : IDisposable }; } - public void Draw(OrbitCamera camera, IEnumerable entities) + public void Draw(ICamera camera, IEnumerable entities) { _shader.Use(); _shader.SetMatrix4("uView", camera.View); diff --git a/src/AcDream.App/Rendering/TerrainRenderer.cs b/src/AcDream.App/Rendering/TerrainRenderer.cs index c82eda0..11cd851 100644 --- a/src/AcDream.App/Rendering/TerrainRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainRenderer.cs @@ -55,7 +55,7 @@ public sealed unsafe class TerrainRenderer : IDisposable _landblocks.Add(gpu); } - public void Draw(OrbitCamera camera) // ICamera in Task 5 + public void Draw(ICamera camera) { _shader.Use(); _shader.SetMatrix4("uView", camera.View); From 7cf6ea267a75b7d271e4cb70b7b5794fc93a09b3 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Apr 2026 20:25:19 +0200 Subject: [PATCH 6/9] feat(app): add FlyCamera with WASD + mouse look Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/Rendering/FlyCamera.cs | 71 ++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/AcDream.App/Rendering/FlyCamera.cs diff --git a/src/AcDream.App/Rendering/FlyCamera.cs b/src/AcDream.App/Rendering/FlyCamera.cs new file mode 100644 index 0000000..9dcd2a5 --- /dev/null +++ b/src/AcDream.App/Rendering/FlyCamera.cs @@ -0,0 +1,71 @@ +// 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)); + } +} From 22f684e8c6a2aebb9f8e6782f6e64f4cc5442023 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Apr 2026 20:27:11 +0200 Subject: [PATCH 7/9] feat(app): add CameraController with F toggle and cursor capture Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/Rendering/CameraController.cs | 31 +++++++ src/AcDream.App/Rendering/GameWindow.cs | 84 +++++++++++++++---- 2 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 src/AcDream.App/Rendering/CameraController.cs diff --git a/src/AcDream.App/Rendering/CameraController.cs b/src/AcDream.App/Rendering/CameraController.cs new file mode 100644 index 0000000..97ff925 --- /dev/null +++ b/src/AcDream.App/Rendering/CameraController.cs @@ -0,0 +1,31 @@ +// 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; + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3c63255..126eccb 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -15,7 +15,8 @@ public sealed class GameWindow : IDisposable private IInputContext? _input; private TerrainRenderer? _terrain; private Shader? _shader; - private OrbitCamera? _camera; + private CameraController? _cameraController; + private IMouse? _capturedMouse; private DatCollection? _dats; private float _lastMouseX; private float _lastMouseY; @@ -42,6 +43,7 @@ public sealed class GameWindow : IDisposable _window = Window.Create(options); _window.Load += OnLoad; + _window.Update += OnUpdate; _window.Render += OnRender; _window.Closing += OnClosing; @@ -55,26 +57,49 @@ public sealed class GameWindow : IDisposable foreach (var kb in _input.Keyboards) kb.KeyDown += (_, key, _) => { - if (key == Key.Escape) - _window!.Close(); + if (key == Key.F) + _cameraController?.ToggleFly(); + else if (key == Key.Escape) + { + if (_cameraController?.IsFlyMode == true) + _cameraController.ToggleFly(); // exit fly, release cursor + else + _window!.Close(); + } }; foreach (var mouse in _input.Mice) { mouse.MouseMove += (m, pos) => { - if (m.IsButtonPressed(MouseButton.Left)) + if (_cameraController is null) return; + + if (_cameraController.IsFlyMode) { - _camera!.Yaw -= (pos.X - _lastMouseX) * 0.005f; - _camera!.Pitch = Math.Clamp( - _camera.Pitch + (pos.Y - _lastMouseY) * 0.005f, - 0.1f, 1.5f); + // 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) => - _camera!.Distance = Math.Clamp(_camera.Distance - scroll.Y * 20f, 50f, 2000f); + { + if (_cameraController is null || _cameraController.IsFlyMode) return; + _cameraController.Orbit.Distance = Math.Clamp( + _cameraController.Orbit.Distance - scroll.Y * 20f, 50f, 2000f); + }; } _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); @@ -89,10 +114,10 @@ public sealed class GameWindow : IDisposable Path.Combine(shadersDir, "mesh.vert"), Path.Combine(shadersDir, "mesh.frag")); - _camera = new OrbitCamera - { - Aspect = _window!.Size.X / (float)_window.Size.Y, - }; + 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; _dats = new DatCollection(_datDir, DatAccessType.Read); @@ -199,11 +224,40 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"hydrated {_entities.Count} entities"); } + 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)); + } + + 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; + } + private void OnRender(double deltaSeconds) { _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); - _terrain?.Draw(_camera!); - _staticMesh?.Draw(_camera!, _entities); + if (_cameraController is not null) + { + _terrain?.Draw(_cameraController.Active); + _staticMesh?.Draw(_cameraController.Active, _entities); + } } private void OnClosing() From 0c0c042dca8c36bb68377a60eb1930187bf33ab9 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Apr 2026 20:29:29 +0200 Subject: [PATCH 8/9] feat(core): add IGameState, IEvents, WorldEvents with replay-on-subscribe Adds WorldEntitySnapshot, IGameState, IEvents abstractions; WorldEvents implements replay-on-subscribe with per-handler exception swallowing; WorldGameState tracks entities; AppPluginHost exposes all three; stubs wired in Program.cs to keep build green ahead of Task 9 live wiring. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/Plugins/AppPluginHost.cs | 10 ++- src/AcDream.App/Program.cs | 4 +- src/AcDream.Core/Plugins/WorldEvents.cs | 56 ++++++++++++ src/AcDream.Core/Plugins/WorldGameState.cs | 14 +++ src/AcDream.Plugin.Abstractions/IEvents.cs | 7 ++ src/AcDream.Plugin.Abstractions/IGameState.cs | 7 ++ .../IPluginHost.cs | 4 +- .../WorldEntitySnapshot.cs | 10 +++ .../Plugins/PluginLoaderTests.cs | 16 ++++ .../Plugins/WorldEventsTests.cs | 87 +++++++++++++++++++ 10 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 src/AcDream.Core/Plugins/WorldEvents.cs create mode 100644 src/AcDream.Core/Plugins/WorldGameState.cs create mode 100644 src/AcDream.Plugin.Abstractions/IEvents.cs create mode 100644 src/AcDream.Plugin.Abstractions/IGameState.cs create mode 100644 src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs create mode 100644 tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs index dbe918f..2916724 100644 --- a/src/AcDream.App/Plugins/AppPluginHost.cs +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -4,6 +4,14 @@ namespace AcDream.App.Plugins; public sealed class AppPluginHost : IPluginHost { - public AppPluginHost(IPluginLogger log) => Log = log; + 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; } } diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 10b3a6b..da0f748 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -15,7 +15,9 @@ if (string.IsNullOrWhiteSpace(datDir)) return 2; } -var host = new AppPluginHost(new SerilogAdapter(Log.Logger)); +var worldGameState = new AcDream.Core.Plugins.WorldGameState(); +var worldEvents = new AcDream.Core.Plugins.WorldEvents(); +var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents); var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins"); Log.Information("scanning plugins in {PluginsDir}", pluginsDir); diff --git a/src/AcDream.Core/Plugins/WorldEvents.cs b/src/AcDream.Core/Plugins/WorldEvents.cs new file mode 100644 index 0000000..086810f --- /dev/null +++ b/src/AcDream.Core/Plugins/WorldEvents.cs @@ -0,0 +1,56 @@ +// 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; + } + } +} diff --git a/src/AcDream.Core/Plugins/WorldGameState.cs b/src/AcDream.Core/Plugins/WorldGameState.cs new file mode 100644 index 0000000..d304827 --- /dev/null +++ b/src/AcDream.Core/Plugins/WorldGameState.cs @@ -0,0 +1,14 @@ +// 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); +} diff --git a/src/AcDream.Plugin.Abstractions/IEvents.cs b/src/AcDream.Plugin.Abstractions/IEvents.cs new file mode 100644 index 0000000..2e498b2 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/IEvents.cs @@ -0,0 +1,7 @@ +// src/AcDream.Plugin.Abstractions/IEvents.cs +namespace AcDream.Plugin.Abstractions; + +public interface IEvents +{ + event Action EntitySpawned; +} diff --git a/src/AcDream.Plugin.Abstractions/IGameState.cs b/src/AcDream.Plugin.Abstractions/IGameState.cs new file mode 100644 index 0000000..e3d640c --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/IGameState.cs @@ -0,0 +1,7 @@ +// src/AcDream.Plugin.Abstractions/IGameState.cs +namespace AcDream.Plugin.Abstractions; + +public interface IGameState +{ + IReadOnlyList Entities { get; } +} diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs index 755dd77..7374ea9 100644 --- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs +++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs @@ -3,9 +3,11 @@ namespace AcDream.Plugin.Abstractions; /// /// Entry point for a plugin into the acdream runtime. The surface will grow -/// across phases as more systems come online. For Phase 1 only IPluginLogger is real. +/// across phases as more systems come online. /// public interface IPluginHost { IPluginLogger Log { get; } + IGameState State { get; } + IEvents Events { get; } } diff --git a/src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs b/src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs new file mode 100644 index 0000000..d47db84 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs @@ -0,0 +1,10 @@ +// 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); diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs index 5947e55..2fdafc9 100644 --- a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs +++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs @@ -28,6 +28,8 @@ public class PluginLoaderTests 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 @@ -37,6 +39,20 @@ public class PluginLoaderTests 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 + { + add { } + remove { } + } + } + [Fact] public void Load_FixtureDll_InstantiatesPluginAndCallsInitialize() { diff --git a/tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs b/tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs new file mode 100644 index 0000000..0868b70 --- /dev/null +++ b/tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs @@ -0,0 +1,87 @@ +// 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); + } +} From 08097b6c7ebb70a7a549187ed90fe97775b11d6f Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Apr 2026 20:31:50 +0200 Subject: [PATCH 9/9] feat(app): wire IGameState+IEvents into Program and SmokePlugin Pass WorldGameState and WorldEvents into GameWindow so OnLoad fires FireEntitySpawned and Add for each hydrated entity. SmokePlugin now subscribes to EntitySpawned in Enable(), unsubscribes in Disable(), and logs the replay count at subscribe time and total seen at disable. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.App/Program.cs | 2 +- src/AcDream.App/Rendering/GameWindow.cs | 23 ++++++++++++++++++++--- src/AcDream.Plugins.Smoke/SmokePlugin.cs | 21 +++++++++++++++++++-- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index da0f748..e989b6e 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -50,7 +50,7 @@ try catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); } } - using var window = new GameWindow(datDir); + using var window = new GameWindow(datDir, worldGameState, worldEvents); window.Run(); } finally diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 126eccb..868c42e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1,3 +1,4 @@ +using AcDream.Core.Plugins; using DatReaderWriter; using DatReaderWriter.Options; using Silk.NET.Input; @@ -10,6 +11,8 @@ namespace AcDream.App.Rendering; public sealed class GameWindow : IDisposable { private readonly string _datDir; + private readonly WorldGameState _worldGameState; + private readonly WorldEvents _worldEvents; private IWindow? _window; private GL? _gl; private IInputContext? _input; @@ -25,7 +28,12 @@ public sealed class GameWindow : IDisposable private TextureCache? _textureCache; private IReadOnlyList _entities = Array.Empty(); - public GameWindow(string datDir) => _datDir = datDir; + public GameWindow(string datDir, WorldGameState worldGameState, WorldEvents worldEvents) + { + _datDir = datDir; + _worldGameState = worldGameState; + _worldEvents = worldEvents; + } public void Run() { @@ -209,14 +217,23 @@ public sealed class GameWindow : IDisposable (lbY - centerY) * 192f, 0f); - hydratedEntities.Add(new AcDream.Core.World.WorldEntity + var hydrated = new AcDream.Core.World.WorldEntity { Id = e.Id, SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId, Position = e.Position + worldOffset, Rotation = e.Rotation, MeshRefs = meshRefs, - }); + }; + hydratedEntities.Add(hydrated); + + 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); } } diff --git a/src/AcDream.Plugins.Smoke/SmokePlugin.cs b/src/AcDream.Plugins.Smoke/SmokePlugin.cs index 310b1a3..d824825 100644 --- a/src/AcDream.Plugins.Smoke/SmokePlugin.cs +++ b/src/AcDream.Plugins.Smoke/SmokePlugin.cs @@ -5,6 +5,7 @@ namespace AcDream.Plugins.Smoke; public sealed class SmokePlugin : IAcDreamPlugin { private IPluginHost? _host; + private int _entitiesSeen; public void Initialize(IPluginHost host) { @@ -12,6 +13,22 @@ public sealed class SmokePlugin : IAcDreamPlugin _host.Log.Info("smoke plugin initialized"); } - public void Enable() => _host?.Log.Info("smoke plugin enabled"); - public void Disable() => _host?.Log.Info("smoke plugin disabled"); + public void Enable() + { + _host?.Log.Info("smoke plugin enabled"); + if (_host is not null) + { + _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 snapshot) => _entitiesSeen++; }