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);
+ }
+}