diff --git a/AcDream.slnx b/AcDream.slnx index 8772319..fa37fc2 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -1,5 +1,13 @@ + + + + + + + + diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj new file mode 100644 index 0000000..e4b69ca --- /dev/null +++ b/src/AcDream.App/AcDream.App.csproj @@ -0,0 +1,49 @@ + + + Exe + net10.0 + enable + enable + latest + true + AcDream.App + true + + + + + + + + + + + + + + PreserveNewest + + + + + + false + true + + + + + <_SmokePluginSourceDir>..\AcDream.Plugins.Smoke\bin\$(Configuration)\net10.0 + <_SmokePluginDestDir>$(OutputPath)plugins\AcDream.Plugins.Smoke + + + + + + diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs new file mode 100644 index 0000000..dbe918f --- /dev/null +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -0,0 +1,9 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Plugins; + +public sealed class AppPluginHost : IPluginHost +{ + public AppPluginHost(IPluginLogger log) => Log = log; + public IPluginLogger Log { get; } +} diff --git a/src/AcDream.App/Plugins/SerilogAdapter.cs b/src/AcDream.App/Plugins/SerilogAdapter.cs new file mode 100644 index 0000000..9f0b4f0 --- /dev/null +++ b/src/AcDream.App/Plugins/SerilogAdapter.cs @@ -0,0 +1,12 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Plugins; + +public sealed class SerilogAdapter : IPluginLogger +{ + private readonly Serilog.ILogger _log; + public SerilogAdapter(Serilog.ILogger log) => _log = log; + public void Info(string message) => _log.Information("{Message}", message); + public void Warn(string message) => _log.Warning("{Message}", message); + public void Error(string message, Exception? exception = null) => _log.Error(exception, "{Message}", message); +} diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs new file mode 100644 index 0000000..10b3a6b --- /dev/null +++ b/src/AcDream.App/Program.cs @@ -0,0 +1,64 @@ +using AcDream.App.Plugins; +using AcDream.App.Rendering; +using AcDream.Core.Plugins; +using Serilog; + +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Console() + .CreateLogger(); + +var datDir = args.FirstOrDefault() ?? Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); +if (string.IsNullOrWhiteSpace(datDir)) +{ + Log.Error("usage: AcDream.App (or set ACDREAM_DAT_DIR)"); + return 2; +} + +var host = new AppPluginHost(new SerilogAdapter(Log.Logger)); + +var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins"); +Log.Information("scanning plugins in {PluginsDir}", pluginsDir); + +var loaded = new List(); +foreach (var result in PluginDiscovery.Scan(pluginsDir)) +{ + if (!result.Success) + { + Log.Warning("plugin discovery failed for {Dir}: {Error}", result.PluginDirectory, result.Error); + continue; + } + + var loadResult = PluginLoader.Load(result.PluginDirectory, result.Manifest!, host); + if (!loadResult.Success) + { + Log.Warning("plugin load failed for {Id}: {Error}", result.Manifest!.Id, loadResult.Error); + continue; + } + + loaded.Add(loadResult); + Log.Information("loaded plugin {Id} ({DisplayName})", result.Manifest!.Id, result.Manifest.DisplayName); +} + +try +{ + foreach (var plugin in loaded) + { + try { plugin.Plugin!.Enable(); } + catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); } + } + + using var window = new GameWindow(datDir); + window.Run(); +} +finally +{ + foreach (var plugin in loaded) + { + try { plugin.Plugin!.Disable(); } + catch (Exception ex) { Log.Error(ex, "plugin disable failed: {Id}", plugin.Manifest.Id); } + } + Log.CloseAndFlush(); +} + +return 0; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs new file mode 100644 index 0000000..59a4bb2 --- /dev/null +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -0,0 +1,138 @@ +using AcDream.Core.Terrain; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using Silk.NET.Input; +using Silk.NET.Maths; +using Silk.NET.OpenGL; +using Silk.NET.Windowing; + +namespace AcDream.App.Rendering; + +public sealed class GameWindow : IDisposable +{ + private readonly string _datDir; + private IWindow? _window; + private GL? _gl; + private IInputContext? _input; + private TerrainRenderer? _terrain; + private Shader? _shader; + private OrbitCamera? _camera; + private DatCollection? _dats; + private float _lastMouseX; + private float _lastMouseY; + + public GameWindow(string datDir) => _datDir = datDir; + + public void Run() + { + var options = WindowOptions.Default with + { + Size = new Vector2D(1280, 720), + Title = "acdream — phase 1", + API = new GraphicsAPI( + ContextAPI.OpenGL, + ContextProfile.Core, + ContextFlags.ForwardCompatible, + new APIVersion(4, 3)), + VSync = true, + }; + + _window = Window.Create(options); + _window.Load += OnLoad; + _window.Render += OnRender; + _window.Closing += OnClosing; + + _window.Run(); + } + + private void OnLoad() + { + _gl = GL.GetApi(_window!); + _input = _window!.CreateInput(); + foreach (var kb in _input.Keyboards) + kb.KeyDown += (_, key, _) => + { + if (key == Key.Escape) + _window!.Close(); + }; + + foreach (var mouse in _input.Mice) + { + mouse.MouseMove += (m, pos) => + { + if (m.IsButtonPressed(MouseButton.Left)) + { + _camera!.Yaw -= (pos.X - _lastMouseX) * 0.005f; + _camera!.Pitch = Math.Clamp( + _camera.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); + } + + _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); + _gl.Enable(EnableCap.DepthTest); + + string shadersDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders"); + _shader = new Shader(_gl, + Path.Combine(shadersDir, "terrain.vert"), + Path.Combine(shadersDir, "terrain.frag")); + + _camera = new OrbitCamera + { + Aspect = _window!.Size.X / (float)_window.Size.Y, + }; + + _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; + } + } + } + + if (block is null) + throw new InvalidOperationException("no landblock found in cell dat"); + + Console.WriteLine($"loaded landblock 0x{landblockId:X8}"); + + var meshData = LandblockMesh.Build(block); + _terrain = new TerrainRenderer(_gl, meshData, _shader); + } + + private void OnRender(double deltaSeconds) + { + _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); + _terrain?.Draw(_camera!); + } + + private void OnClosing() + { + _terrain?.Dispose(); + _shader?.Dispose(); + _dats?.Dispose(); + _input?.Dispose(); + _gl?.Dispose(); + } + + public void Dispose() => _window?.Dispose(); +} diff --git a/src/AcDream.App/Rendering/OrbitCamera.cs b/src/AcDream.App/Rendering/OrbitCamera.cs new file mode 100644 index 0000000..95f9977 --- /dev/null +++ b/src/AcDream.App/Rendering/OrbitCamera.cs @@ -0,0 +1,28 @@ +using System.Numerics; + +namespace AcDream.App.Rendering; + +public sealed class OrbitCamera +{ + public Vector3 Target { get; set; } = new(96, 96, 0); // center of a 192x192 landblock + public float Distance { get; set; } = 300f; + public float Yaw { get; set; } = MathF.PI / 4f; + public float Pitch { get; set; } = MathF.PI / 6f; + public float FovY { get; set; } = MathF.PI / 3f; + public float Aspect { get; set; } = 16f / 9f; + + public Matrix4x4 View + { + get + { + var eye = Target + new Vector3( + Distance * MathF.Cos(Pitch) * MathF.Cos(Yaw), + Distance * MathF.Cos(Pitch) * MathF.Sin(Yaw), + Distance * MathF.Sin(Pitch)); + return Matrix4x4.CreateLookAt(eye, Target, Vector3.UnitZ); + } + } + + public Matrix4x4 Projection + => Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f); +} diff --git a/src/AcDream.App/Rendering/Shader.cs b/src/AcDream.App/Rendering/Shader.cs new file mode 100644 index 0000000..4c97c23 --- /dev/null +++ b/src/AcDream.App/Rendering/Shader.cs @@ -0,0 +1,50 @@ +using System.Numerics; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +public sealed class Shader : IDisposable +{ + private readonly GL _gl; + public uint Program { get; } + + public Shader(GL gl, string vertexPath, string fragmentPath) + { + _gl = gl; + uint vert = Compile(File.ReadAllText(vertexPath), ShaderType.VertexShader); + uint frag = Compile(File.ReadAllText(fragmentPath), ShaderType.FragmentShader); + + Program = _gl.CreateProgram(); + _gl.AttachShader(Program, vert); + _gl.AttachShader(Program, frag); + _gl.LinkProgram(Program); + _gl.GetProgram(Program, ProgramPropertyARB.LinkStatus, out int linked); + if (linked == 0) + throw new Exception("program link failed: " + _gl.GetProgramInfoLog(Program)); + _gl.DetachShader(Program, vert); + _gl.DetachShader(Program, frag); + _gl.DeleteShader(vert); + _gl.DeleteShader(frag); + } + + private uint Compile(string source, ShaderType type) + { + uint id = _gl.CreateShader(type); + _gl.ShaderSource(id, source); + _gl.CompileShader(id); + _gl.GetShader(id, ShaderParameterName.CompileStatus, out int ok); + if (ok == 0) + throw new Exception($"{type} compile failed: " + _gl.GetShaderInfoLog(id)); + return id; + } + + public void Use() => _gl.UseProgram(Program); + + public unsafe void SetMatrix4(string name, Matrix4x4 m) + { + int loc = _gl.GetUniformLocation(Program, name); + _gl.UniformMatrix4(loc, 1, false, (float*)&m); + } + + public void Dispose() => _gl.DeleteProgram(Program); +} diff --git a/src/AcDream.App/Rendering/Shaders/terrain.frag b/src/AcDream.App/Rendering/Shaders/terrain.frag new file mode 100644 index 0000000..d6e747b --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/terrain.frag @@ -0,0 +1,14 @@ +#version 430 core +in float vHeight; +out vec4 fragColor; + +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); +} diff --git a/src/AcDream.App/Rendering/Shaders/terrain.vert b/src/AcDream.App/Rendering/Shaders/terrain.vert new file mode 100644 index 0000000..1f3b04f --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/terrain.vert @@ -0,0 +1,14 @@ +#version 430 core +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTex; + +uniform mat4 uView; +uniform mat4 uProjection; + +out float vHeight; + +void main() { + vHeight = aPos.z; + gl_Position = uProjection * uView * vec4(aPos, 1.0); +} diff --git a/src/AcDream.App/Rendering/TerrainRenderer.cs b/src/AcDream.App/Rendering/TerrainRenderer.cs new file mode 100644 index 0000000..e61c301 --- /dev/null +++ b/src/AcDream.App/Rendering/TerrainRenderer.cs @@ -0,0 +1,64 @@ +using AcDream.Core.Terrain; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +public sealed unsafe class TerrainRenderer : IDisposable +{ + private readonly GL _gl; + private readonly Shader _shader; + private readonly uint _vao; + private readonly uint _vbo; + private readonly uint _ebo; + private readonly int _indexCount; + + public TerrainRenderer(GL gl, LandblockMeshData meshData, Shader shader) + { + _gl = gl; + _shader = shader; + _indexCount = meshData.Indices.Length; + + _vao = _gl.GenVertexArray(); + _gl.BindVertexArray(_vao); + + _vbo = _gl.GenBuffer(); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _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); + 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); + _gl.EnableVertexAttribArray(1); + _gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float))); + _gl.EnableVertexAttribArray(2); + _gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float))); + + _gl.BindVertexArray(0); + } + + public void Draw(OrbitCamera 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.BindVertexArray(0); + } + + public void Dispose() + { + _gl.DeleteBuffer(_vbo); + _gl.DeleteBuffer(_ebo); + _gl.DeleteVertexArray(_vao); + } +} diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj new file mode 100644 index 0000000..a919781 --- /dev/null +++ b/src/AcDream.Core/AcDream.Core.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + latest + true + + + + + + + + + diff --git a/src/AcDream.Core/Plugins/LoadedPlugin.cs b/src/AcDream.Core/Plugins/LoadedPlugin.cs new file mode 100644 index 0000000..a3f6d24 --- /dev/null +++ b/src/AcDream.Core/Plugins/LoadedPlugin.cs @@ -0,0 +1,20 @@ +using System.Runtime.Loader; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +/// +/// Outcome of a plugin load attempt. +/// On success, is the instantiated plugin, +/// owns its assembly, and is null. +/// On failure, and are null and +/// describes what went wrong. +/// +public sealed record LoadedPlugin( + PluginManifest Manifest, + IAcDreamPlugin? Plugin, + AssemblyLoadContext? LoadContext, + Exception? Error) +{ + public bool Success => Plugin is not null && Error is null; +} diff --git a/src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs b/src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs new file mode 100644 index 0000000..6ce3968 --- /dev/null +++ b/src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs @@ -0,0 +1,32 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace AcDream.Core.Plugins; + +/// +/// Collectible ALC for a single plugin. Resolves assemblies from the plugin's +/// own directory EXCEPT for AcDream.Plugin.Abstractions, which must come from +/// the default (host) context so type identity for IAcDreamPlugin is preserved. +/// +internal sealed class PluginAssemblyLoadContext : AssemblyLoadContext +{ + private const string AbstractionsAssemblyName = "AcDream.Plugin.Abstractions"; + + private readonly AssemblyDependencyResolver _resolver; + + public PluginAssemblyLoadContext(string pluginDirectory, string pluginEntryPath) + : base(name: pluginDirectory, isCollectible: true) + { + _resolver = new AssemblyDependencyResolver(pluginEntryPath); + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + // Share the abstractions assembly with the host — do NOT reload it in the plugin ALC + if (assemblyName.Name == AbstractionsAssemblyName) + return null; + + var path = _resolver.ResolveAssemblyToPath(assemblyName); + return path is null ? null : LoadFromAssemblyPath(path); + } +} diff --git a/src/AcDream.Core/Plugins/PluginDiscovery.cs b/src/AcDream.Core/Plugins/PluginDiscovery.cs new file mode 100644 index 0000000..16b524d --- /dev/null +++ b/src/AcDream.Core/Plugins/PluginDiscovery.cs @@ -0,0 +1,38 @@ +namespace AcDream.Core.Plugins; + +public sealed record PluginDiscoveryResult( + string PluginDirectory, + PluginManifest? Manifest, + Exception? Error) +{ + public bool Success => Manifest is not null && Error is null; +} + +public static class PluginDiscovery +{ + public static IReadOnlyList Scan(string pluginsRootDirectory) + { + if (!Directory.Exists(pluginsRootDirectory)) + return Array.Empty(); + + var results = new List(); + foreach (var subdir in Directory.EnumerateDirectories(pluginsRootDirectory).OrderBy(p => p, StringComparer.Ordinal)) + { + var manifestPath = Path.Combine(subdir, "plugin.json"); + if (!File.Exists(manifestPath)) + continue; + + try + { + var json = File.ReadAllText(manifestPath); + var manifest = PluginManifest.Parse(json); + results.Add(new PluginDiscoveryResult(subdir, manifest, null)); + } + catch (Exception ex) + { + results.Add(new PluginDiscoveryResult(subdir, null, ex)); + } + } + return results; + } +} diff --git a/src/AcDream.Core/Plugins/PluginLoader.cs b/src/AcDream.Core/Plugins/PluginLoader.cs new file mode 100644 index 0000000..ba2ba07 --- /dev/null +++ b/src/AcDream.Core/Plugins/PluginLoader.cs @@ -0,0 +1,60 @@ +using System.Reflection; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +public static class PluginLoader +{ + /// + /// Load a plugin DLL from into a collectible + /// , find the first type + /// implementing , instantiate it, and call its + /// with the supplied host. Any failure + /// is returned as a failed rather than thrown. + /// + public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host) + { + var dllPath = Path.Combine(pluginDirectory, manifest.EntryDll); + if (!File.Exists(dllPath)) + return new LoadedPlugin( + manifest, + Plugin: null, + LoadContext: null, + Error: new FileNotFoundException($"entry dll not found: {dllPath}", dllPath)); + + try + { + var alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath); + var asm = alc.LoadFromAssemblyPath(dllPath); + + IEnumerable types; + try + { + types = asm.GetTypes(); + } + catch (ReflectionTypeLoadException rtle) + { + types = rtle.Types.OfType(); + } + + var pluginType = types + .FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t)); + + if (pluginType is null) + return new LoadedPlugin( + manifest, + Plugin: null, + LoadContext: null, + Error: new InvalidOperationException( + $"no IAcDreamPlugin implementation found in {manifest.EntryDll}")); + + var instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; + instance.Initialize(host); + return new LoadedPlugin(manifest, instance, alc, Error: null); + } + catch (Exception ex) + { + return new LoadedPlugin(manifest, Plugin: null, LoadContext: null, Error: ex); + } + } +} diff --git a/src/AcDream.Core/Plugins/PluginManifest.cs b/src/AcDream.Core/Plugins/PluginManifest.cs new file mode 100644 index 0000000..da16dcf --- /dev/null +++ b/src/AcDream.Core/Plugins/PluginManifest.cs @@ -0,0 +1,71 @@ +using System.Text.Json; + +namespace AcDream.Core.Plugins; + +public sealed record PluginManifest( + string Id, + string DisplayName, + string Version, + string EntryDll, + int ApiVersion, + IReadOnlyList Dependencies) +{ + public static PluginManifest Parse(string json) + { + PluginManifestDto? dto; + try + { + dto = JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException ex) + { + throw new PluginManifestException($"invalid json: {ex.Message}", ex); + } + + if (dto is null) + throw new PluginManifestException("manifest is empty"); + + Require(dto.Id, "id"); + Require(dto.DisplayName, "displayName"); + Require(dto.Version, "version"); + Require(dto.EntryDll, "entryDll"); + if (dto.ApiVersion <= 0) + throw new PluginManifestException("apiVersion must be >= 1"); + + return new PluginManifest( + dto.Id!, + dto.DisplayName!, + dto.Version!, + dto.EntryDll!, + dto.ApiVersion, + dto.Dependencies ?? Array.Empty()); + } + + private static void Require(string? value, string jsonFieldName) + { + if (string.IsNullOrWhiteSpace(value)) + throw new PluginManifestException($"missing required field: {jsonFieldName}"); + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + private sealed class PluginManifestDto + { + public string? Id { get; set; } + public string? DisplayName { get; set; } + public string? Version { get; set; } + public string? EntryDll { get; set; } + public int ApiVersion { get; set; } + public IReadOnlyList? Dependencies { get; set; } + } +} + +public sealed class PluginManifestException : Exception +{ + public PluginManifestException(string message) : base(message) { } + public PluginManifestException(string message, Exception inner) : base(message, inner) { } +} diff --git a/src/AcDream.Core/Terrain/LandblockMesh.cs b/src/AcDream.Core/Terrain/LandblockMesh.cs new file mode 100644 index 0000000..0cb4f7d --- /dev/null +++ b/src/AcDream.Core/Terrain/LandblockMesh.cs @@ -0,0 +1,50 @@ +using System.Numerics; +using DatReaderWriter.DBObjs; + +namespace AcDream.Core.Terrain; + +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 float HeightScale = 2.0f; // byte height -> world z + + public static LandblockMeshData Build(LandBlock block) + { + var vertices = new Vertex[VerticesPerSide * VerticesPerSide]; + for (int y = 0; y < VerticesPerSide; y++) + { + for (int x = 0; x < VerticesPerSide; x++) + { + int i = y * VerticesPerSide + x; + float height = block.Height[i] * HeightScale; + vertices[i] = new Vertex( + Position: new Vector3(x * CellSize, y * CellSize, height), + Normal: Vector3.UnitZ, + TexCoord: new Vector2(x / (float)CellsPerSide, y / (float)CellsPerSide)); + } + } + + var indices = new uint[CellsPerSide * CellsPerSide * 6]; + int idx = 0; + for (int y = 0; y < CellsPerSide; y++) + { + for (int x = 0; x < CellsPerSide; x++) + { + uint a = (uint)(y * VerticesPerSide + x); + uint b = (uint)(y * VerticesPerSide + x + 1); + uint c = (uint)((y + 1) * VerticesPerSide + x); + uint d = (uint)((y + 1) * VerticesPerSide + x + 1); + // two triangles per cell, CCW + indices[idx++] = a; indices[idx++] = b; indices[idx++] = d; + indices[idx++] = a; indices[idx++] = d; indices[idx++] = c; + } + } + + return new LandblockMeshData(vertices, indices); + } +} diff --git a/src/AcDream.Core/Terrain/Vertex.cs b/src/AcDream.Core/Terrain/Vertex.cs new file mode 100644 index 0000000..b590ef2 --- /dev/null +++ b/src/AcDream.Core/Terrain/Vertex.cs @@ -0,0 +1,5 @@ +using System.Numerics; + +namespace AcDream.Core.Terrain; + +public readonly record struct Vertex(Vector3 Position, Vector3 Normal, Vector2 TexCoord); diff --git a/src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj b/src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj new file mode 100644 index 0000000..10225ce --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj @@ -0,0 +1,9 @@ + + + net10.0 + enable + enable + latest + true + + diff --git a/src/AcDream.Plugin.Abstractions/IAcDreamPlugin.cs b/src/AcDream.Plugin.Abstractions/IAcDreamPlugin.cs new file mode 100644 index 0000000..63e11c6 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/IAcDreamPlugin.cs @@ -0,0 +1,14 @@ +// src/AcDream.Plugin.Abstractions/IAcDreamPlugin.cs +namespace AcDream.Plugin.Abstractions; + +public interface IAcDreamPlugin +{ + /// + /// Called exactly once, before . The plugin should stash the + /// host reference and do any one-time setup that doesn't depend on a connected world. + /// + void Initialize(IPluginHost host); + + void Enable(); + void Disable(); +} diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs new file mode 100644 index 0000000..755dd77 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs @@ -0,0 +1,11 @@ +// src/AcDream.Plugin.Abstractions/IPluginHost.cs +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. +/// +public interface IPluginHost +{ + IPluginLogger Log { get; } +} diff --git a/src/AcDream.Plugin.Abstractions/IPluginLogger.cs b/src/AcDream.Plugin.Abstractions/IPluginLogger.cs new file mode 100644 index 0000000..e3ed43f --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/IPluginLogger.cs @@ -0,0 +1,9 @@ +// src/AcDream.Plugin.Abstractions/IPluginLogger.cs +namespace AcDream.Plugin.Abstractions; + +public interface IPluginLogger +{ + void Info(string message); + void Warn(string message); + void Error(string message, Exception? exception = null); +} diff --git a/src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj b/src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj new file mode 100644 index 0000000..0f5b0c9 --- /dev/null +++ b/src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + enable + latest + + + + + false + runtime + + + diff --git a/src/AcDream.Plugins.Smoke/SmokePlugin.cs b/src/AcDream.Plugins.Smoke/SmokePlugin.cs new file mode 100644 index 0000000..310b1a3 --- /dev/null +++ b/src/AcDream.Plugins.Smoke/SmokePlugin.cs @@ -0,0 +1,17 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.Smoke; + +public sealed class SmokePlugin : IAcDreamPlugin +{ + private IPluginHost? _host; + + public void Initialize(IPluginHost host) + { + _host = host; + _host.Log.Info("smoke plugin initialized"); + } + + public void Enable() => _host?.Log.Info("smoke plugin enabled"); + public void Disable() => _host?.Log.Info("smoke plugin disabled"); +} diff --git a/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj b/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj new file mode 100644 index 0000000..46a3efe --- /dev/null +++ b/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + latest + false + + + + + false + runtime + + + diff --git a/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs b/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs new file mode 100644 index 0000000..8048fb6 --- /dev/null +++ b/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs @@ -0,0 +1,20 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Tests.Fixtures.HelloPlugin; + +public sealed class HelloPlugin : IAcDreamPlugin +{ + public int InitializeCount { get; private set; } + public int EnableCount { get; private set; } + public int DisableCount { get; private set; } + public IPluginHost? ReceivedHost { get; private set; } + + public void Initialize(IPluginHost host) + { + ReceivedHost = host; + InitializeCount++; + } + + public void Enable() => EnableCount++; + public void Disable() => DisableCount++; +} diff --git a/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj b/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj new file mode 100644 index 0000000..2104378 --- /dev/null +++ b/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + false + true + + + + \ No newline at end of file diff --git a/tests/AcDream.Core.Tests/Plugins/PluginDiscoveryTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginDiscoveryTests.cs new file mode 100644 index 0000000..03dfec6 --- /dev/null +++ b/tests/AcDream.Core.Tests/Plugins/PluginDiscoveryTests.cs @@ -0,0 +1,91 @@ +using AcDream.Core.Plugins; + +namespace AcDream.Core.Tests.Plugins; + +public class PluginDiscoveryTests : IDisposable +{ + private readonly string _tempDir; + + public PluginDiscoveryTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "acdream-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + private string WriteManifest(string folder, string json) + { + var dir = Path.Combine(_tempDir, folder); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "plugin.json"); + File.WriteAllText(path, json); + return dir; + } + + private const string ValidManifest = """ + { + "id": "acdream.example", + "displayName": "Example", + "version": "0.1.0", + "entryDll": "example.dll", + "apiVersion": 1 + } + """; + + [Fact] + public void Scan_EmptyDirectory_ReturnsEmpty() + { + var results = PluginDiscovery.Scan(_tempDir); + Assert.Empty(results); + } + + [Fact] + public void Scan_NonexistentDirectory_ReturnsEmpty() + { + var results = PluginDiscovery.Scan(Path.Combine(_tempDir, "does-not-exist")); + Assert.Empty(results); + } + + [Fact] + public void Scan_OneValidPlugin_ReturnsOneSuccess() + { + var pluginDir = WriteManifest("example", ValidManifest); + + var results = PluginDiscovery.Scan(_tempDir); + + var single = Assert.Single(results); + Assert.True(single.Success); + Assert.NotNull(single.Manifest); + Assert.Equal("acdream.example", single.Manifest!.Id); + Assert.Equal(pluginDir, single.PluginDirectory); + } + + [Fact] + public void Scan_SubdirWithoutManifest_IsSkipped() + { + Directory.CreateDirectory(Path.Combine(_tempDir, "noplugin")); + WriteManifest("real", ValidManifest); + + var results = PluginDiscovery.Scan(_tempDir); + + Assert.Single(results); + } + + [Fact] + public void Scan_InvalidManifest_ReturnsFailureButKeepsGoing() + { + WriteManifest("broken", "{ not json"); + WriteManifest("ok", ValidManifest); + + var results = PluginDiscovery.Scan(_tempDir).ToList(); + + Assert.Equal(2, results.Count); + Assert.Contains(results, r => !r.Success && r.Error != null); + Assert.Contains(results, r => r.Success); + } +} diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs new file mode 100644 index 0000000..5947e55 --- /dev/null +++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs @@ -0,0 +1,102 @@ +using AcDream.Core.Plugins; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Tests.Plugins; + +public class PluginLoaderTests +{ + private static string FixturePluginPath() + { + // walk up from the test bin dir to the repo root, then into the fixture's build output + var baseDir = AppContext.BaseDirectory; + var configuration = new DirectoryInfo(baseDir).Parent!.Name; // Debug / Release + var repoRoot = FindRepoRoot(baseDir); + return Path.Combine( + repoRoot, + "tests", "AcDream.Core.Tests.Fixtures.HelloPlugin", "bin", configuration, "net10.0", + "AcDream.Core.Tests.Fixtures.HelloPlugin.dll"); + } + + private static string FindRepoRoot(string startDir) + { + var dir = new DirectoryInfo(startDir); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "AcDream.slnx"))) + dir = dir.Parent; + return dir?.FullName ?? throw new InvalidOperationException("repo root not found"); + } + + private sealed class StubHost : IPluginHost + { + public IPluginLogger Log { get; } = new StubLogger(); + } + + private sealed class StubLogger : IPluginLogger + { + public void Info(string message) { } + public void Warn(string message) { } + public void Error(string message, Exception? exception = null) { } + } + + [Fact] + public void Load_FixtureDll_InstantiatesPluginAndCallsInitialize() + { + var dllPath = FixturePluginPath(); + Assert.True(File.Exists(dllPath), $"fixture dll not found: {dllPath}"); + + var host = new StubHost(); + var manifest = new PluginManifest( + Id: "acdream.test.hello", + DisplayName: "Hello", + Version: "0.0.1", + EntryDll: Path.GetFileName(dllPath), + ApiVersion: 1, + Dependencies: Array.Empty()); + + var loaded = PluginLoader.Load( + pluginDirectory: Path.GetDirectoryName(dllPath)!, + manifest: manifest, + host: host); + + Assert.True(loaded.Success); + Assert.NotNull(loaded.Plugin); + Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name); + } + + [Fact] + public void Load_MissingDll_ReturnsFailure() + { + var host = new StubHost(); + var manifest = new PluginManifest( + Id: "x", + DisplayName: "X", + Version: "0.0.1", + EntryDll: "nope.dll", + ApiVersion: 1, + Dependencies: Array.Empty()); + + var loaded = PluginLoader.Load("/does/not/exist", manifest, host); + + Assert.False(loaded.Success); + Assert.NotNull(loaded.Error); + } + + [Fact] + public void Load_DllWithNoPluginImpl_ReturnsFailure() + { + // Use AcDream.Core.dll itself — it has no IAcDreamPlugin impl + var coreDllDir = AppContext.BaseDirectory; + var host = new StubHost(); + var manifest = new PluginManifest( + Id: "x", + DisplayName: "X", + Version: "0.0.1", + EntryDll: "AcDream.Core.dll", + ApiVersion: 1, + Dependencies: Array.Empty()); + + var loaded = PluginLoader.Load(coreDllDir, manifest, host); + + Assert.False(loaded.Success); + Assert.Contains("IAcDreamPlugin", loaded.Error!.Message); + } +} diff --git a/tests/AcDream.Core.Tests/Plugins/PluginManifestTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginManifestTests.cs new file mode 100644 index 0000000..cfe08bc --- /dev/null +++ b/tests/AcDream.Core.Tests/Plugins/PluginManifestTests.cs @@ -0,0 +1,62 @@ +using AcDream.Core.Plugins; + +namespace AcDream.Core.Tests.Plugins; + +public class PluginManifestTests +{ + [Fact] + public void Parse_ValidManifest_ReturnsManifest() + { + const string json = """ + { + "id": "acdream.smoke", + "displayName": "Smoke Test", + "version": "0.1.0", + "entryDll": "AcDream.Plugins.Smoke.dll", + "apiVersion": 1 + } + """; + + var manifest = PluginManifest.Parse(json); + + Assert.Equal("acdream.smoke", manifest.Id); + Assert.Equal("Smoke Test", manifest.DisplayName); + Assert.Equal("0.1.0", manifest.Version); + Assert.Equal("AcDream.Plugins.Smoke.dll", manifest.EntryDll); + Assert.Equal(1, manifest.ApiVersion); + } + + [Fact] + public void Parse_MissingRequiredField_Throws() + { + const string json = """ + { "id": "x", "version": "0.1.0", "entryDll": "x.dll", "apiVersion": 1 } + """; + + var ex = Assert.Throws(() => PluginManifest.Parse(json)); + Assert.Equal("missing required field: displayName", ex.Message); + } + + [Fact] + public void Parse_MalformedJson_Throws() + { + Assert.Throws(() => PluginManifest.Parse("{ not json")); + } + + [Fact] + public void Parse_EmptyDependencies_DefaultsToEmptyList() + { + const string json = """ + { + "id": "x", + "displayName": "X", + "version": "0.1.0", + "entryDll": "x.dll", + "apiVersion": 1 + } + """; + + var manifest = PluginManifest.Parse(json); + Assert.Empty(manifest.Dependencies); + } +} diff --git a/tests/AcDream.Core.Tests/SmokeTest.cs b/tests/AcDream.Core.Tests/SmokeTest.cs new file mode 100644 index 0000000..3f71101 --- /dev/null +++ b/tests/AcDream.Core.Tests/SmokeTest.cs @@ -0,0 +1,11 @@ +// tests/AcDream.Core.Tests/SmokeTest.cs +namespace AcDream.Core.Tests; + +public class SmokeTest +{ + [Fact] + public void TestProject_IsWired() + { + Assert.True(true); + } +} diff --git a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs new file mode 100644 index 0000000..c4e63ad --- /dev/null +++ b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs @@ -0,0 +1,76 @@ +using System.Numerics; +using AcDream.Core.Terrain; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.Terrain; + +public class LandblockMeshTests +{ + private static LandBlock BuildFlatLandBlock(byte heightIndex = 0) + { + var block = new LandBlock + { + HasObjects = false, + Terrain = new TerrainInfo[81], + Height = new byte[81], + }; + for (int i = 0; i < 81; i++) + { + block.Terrain[i] = (ushort)0; + block.Height[i] = heightIndex; + } + return block; + } + + [Fact] + public void Build_FlatBlock_Produces81VerticesAnd128Triangles() + { + var block = BuildFlatLandBlock(); + + var mesh = LandblockMesh.Build(block); + + Assert.Equal(81, mesh.Vertices.Length); + Assert.Equal(128 * 3, mesh.Indices.Length); + } + + [Fact] + public void Build_Vertices_Cover192x192WorldUnits() + { + var block = BuildFlatLandBlock(); + + var mesh = LandblockMesh.Build(block); + + var minX = mesh.Vertices.Min(v => v.Position.X); + var maxX = mesh.Vertices.Max(v => v.Position.X); + var minY = mesh.Vertices.Min(v => v.Position.Y); + var maxY = mesh.Vertices.Max(v => v.Position.Y); + + Assert.Equal(0.0f, minX); + Assert.Equal(192.0f, maxX); + Assert.Equal(0.0f, minY); + Assert.Equal(192.0f, maxY); + } + + [Fact] + public void Build_FlatBlock_AllVerticesSameZ() + { + var block = BuildFlatLandBlock(heightIndex: 10); + + var mesh = LandblockMesh.Build(block); + + var zs = mesh.Vertices.Select(v => v.Position.Z).Distinct().ToArray(); + Assert.Single(zs); + } + + [Fact] + public void Build_HeightValues_ScaleByTwo() + { + var block = BuildFlatLandBlock(heightIndex: 5); + + var mesh = LandblockMesh.Build(block); + + // 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); + } +}