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..e989b6e 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); @@ -48,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/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/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)); + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index f9c742d..868c42e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1,6 +1,5 @@ -using AcDream.Core.Terrain; +using AcDream.Core.Plugins; using DatReaderWriter; -using DatReaderWriter.DBObjs; using DatReaderWriter.Options; using Silk.NET.Input; using Silk.NET.Maths; @@ -12,12 +11,15 @@ 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; private TerrainRenderer? _terrain; private Shader? _shader; - private OrbitCamera? _camera; + private CameraController? _cameraController; + private IMouse? _capturedMouse; private DatCollection? _dats; private float _lastMouseX; private float _lastMouseY; @@ -26,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() { @@ -44,6 +51,7 @@ public sealed class GameWindow : IDisposable _window = Window.Create(options); _window.Load += OnLoad; + _window.Update += OnUpdate; _window.Render += OnRender; _window.Closing += OnClosing; @@ -57,26 +65,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); @@ -91,82 +122,74 @@ 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); - // 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); - _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,26 +207,74 @@ public sealed class GameWindow : IDisposable if (meshRefs.Count > 0) { - hydratedEntities.Add(new AcDream.Core.World.WorldEntity + // 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); + + var hydrated = new AcDream.Core.World.WorldEntity { Id = e.Id, SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId, - Position = e.Position, + 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); } } _entities = hydratedEntities; - Console.WriteLine($"hydrated {_entities.Count} entities on landblock 0x{landblockId:X8}"); + 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() 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/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/StaticMeshRenderer.cs b/src/AcDream.App/Rendering/StaticMeshRenderer.cs index 621cc31..424eb8b 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); @@ -71,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/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); +} diff --git a/src/AcDream.App/Rendering/TerrainRenderer.cs b/src/AcDream.App/Rendering/TerrainRenderer.cs index e61c301..11cd851 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); @@ -41,24 +48,49 @@ 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); + _landblocks.Add(gpu); } - public void Draw(OrbitCamera camera) + public void Draw(ICamera camera) { _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; } } 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/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.Core/Terrain/LandblockMesh.cs b/src/AcDream.Core/Terrain/LandblockMesh.cs index 9c3c100..860353e 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,24 @@ 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 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; + 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 +60,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/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/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++; } 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); + } +} diff --git a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs index b2853f6..0d91e6e 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,38 @@ 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 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, // TerrainTextureType 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 +127,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)