Merge phase-1/terrain-and-plugins: terrain rendering + plugin scaffold
Phase 1 MVP complete. 16 commits implementing: - Solution reorg into Plugin.Abstractions / Core / App / Tests + Smoke plugin - Plugin system: manifest parsing, directory discovery, collectible-ALC loader with cross-ALC type identity preserved, failure isolation - Terrain: LandblockMesh generator, Silk.NET window, GPU upload, height-ramp shader, orbit camera with mouse+scroll input - End-to-end: Serilog + AppPluginHost + SerilogAdapter, Smoke plugin round-trips through Initialize/Enable/Disable alongside terrain render 17 xUnit tests green, build clean, smoke verified visually against retail dats (Holtburg 0xA9B4FFFF renders as expected).
This commit is contained in:
commit
2089bf3d56
33 changed files with 1234 additions and 0 deletions
|
|
@ -1,5 +1,13 @@
|
||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/AcDream.App/AcDream.App.csproj" />
|
||||||
<Project Path="src/AcDream.Cli/AcDream.Cli.csproj" />
|
<Project Path="src/AcDream.Cli/AcDream.Cli.csproj" />
|
||||||
|
<Project Path="src/AcDream.Core/AcDream.Core.csproj" />
|
||||||
|
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
|
||||||
|
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj" />
|
||||||
|
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|
|
||||||
49
src/AcDream.App/AcDream.App.csproj
Normal file
49
src/AcDream.App/AcDream.App.csproj
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<RootNamespace>AcDream.App</RootNamespace>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
|
||||||
|
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
|
||||||
|
<PackageReference Include="Silk.NET.Input" Version="2.23.0" />
|
||||||
|
<PackageReference Include="Serilog" Version="4.0.2" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="Rendering\Shaders\*.*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Build the smoke plugin first and copy it into plugins/AcDream.Plugins.Smoke/ -->
|
||||||
|
<ProjectReference Include="..\AcDream.Plugins.Smoke\AcDream.Plugins.Smoke.csproj">
|
||||||
|
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||||
|
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||||
|
</ProjectReference>
|
||||||
|
</ItemGroup>
|
||||||
|
<Target Name="CopySmokePluginToPluginsDir" AfterTargets="Build">
|
||||||
|
<PropertyGroup>
|
||||||
|
<_SmokePluginSourceDir>..\AcDream.Plugins.Smoke\bin\$(Configuration)\net10.0</_SmokePluginSourceDir>
|
||||||
|
<_SmokePluginDestDir>$(OutputPath)plugins\AcDream.Plugins.Smoke</_SmokePluginDestDir>
|
||||||
|
</PropertyGroup>
|
||||||
|
<MakeDir Directories="$(_SmokePluginDestDir)" />
|
||||||
|
<Copy
|
||||||
|
SourceFiles="$(_SmokePluginSourceDir)\AcDream.Plugins.Smoke.dll"
|
||||||
|
DestinationFolder="$(_SmokePluginDestDir)"
|
||||||
|
SkipUnchangedFiles="true" />
|
||||||
|
<WriteLinesToFile
|
||||||
|
File="$(_SmokePluginDestDir)\plugin.json"
|
||||||
|
Overwrite="true"
|
||||||
|
Lines="{ "id": "acdream.smoke", "displayName": "Smoke Plugin", "version": "0.1.0", "entryDll": "AcDream.Plugins.Smoke.dll", "apiVersion": 1 }" />
|
||||||
|
</Target>
|
||||||
|
</Project>
|
||||||
9
src/AcDream.App/Plugins/AppPluginHost.cs
Normal file
9
src/AcDream.App/Plugins/AppPluginHost.cs
Normal file
|
|
@ -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; }
|
||||||
|
}
|
||||||
12
src/AcDream.App/Plugins/SerilogAdapter.cs
Normal file
12
src/AcDream.App/Plugins/SerilogAdapter.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
64
src/AcDream.App/Program.cs
Normal file
64
src/AcDream.App/Program.cs
Normal file
|
|
@ -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 <dat-directory> (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<LoadedPlugin>();
|
||||||
|
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;
|
||||||
138
src/AcDream.App/Rendering/GameWindow.cs
Normal file
138
src/AcDream.App/Rendering/GameWindow.cs
Normal file
|
|
@ -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<int>(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<T>
|
||||||
|
// (returns null on miss) rather than TryGet to sidestep
|
||||||
|
// [MaybeNullWhen(false)] nullable-generic analysis under
|
||||||
|
// TreatWarningsAsErrors.
|
||||||
|
uint landblockId = 0xA9B4FFFFu;
|
||||||
|
var block = _dats.Get<LandBlock>(landblockId);
|
||||||
|
if (block is null)
|
||||||
|
{
|
||||||
|
foreach (var file in _dats.Cell.Tree)
|
||||||
|
{
|
||||||
|
if ((file.Id & 0xFFFFu) == 0xFFFFu)
|
||||||
|
{
|
||||||
|
landblockId = file.Id;
|
||||||
|
block = _dats.Get<LandBlock>(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();
|
||||||
|
}
|
||||||
28
src/AcDream.App/Rendering/OrbitCamera.cs
Normal file
28
src/AcDream.App/Rendering/OrbitCamera.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
50
src/AcDream.App/Rendering/Shader.cs
Normal file
50
src/AcDream.App/Rendering/Shader.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
14
src/AcDream.App/Rendering/Shaders/terrain.frag
Normal file
14
src/AcDream.App/Rendering/Shaders/terrain.frag
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
14
src/AcDream.App/Rendering/Shaders/terrain.vert
Normal file
14
src/AcDream.App/Rendering/Shaders/terrain.vert
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
64
src/AcDream.App/Rendering/TerrainRenderer.cs
Normal file
64
src/AcDream.App/Rendering/TerrainRenderer.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/AcDream.Core/AcDream.Core.csproj
Normal file
16
src/AcDream.Core/AcDream.Core.csproj
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.4" />
|
||||||
|
<PackageReference Include="Serilog" Version="4.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
20
src/AcDream.Core/Plugins/LoadedPlugin.cs
Normal file
20
src/AcDream.Core/Plugins/LoadedPlugin.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
using System.Runtime.Loader;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outcome of a plugin load attempt.
|
||||||
|
/// <para>On success, <see cref="Plugin"/> is the instantiated plugin, <see cref="LoadContext"/>
|
||||||
|
/// owns its assembly, and <see cref="Error"/> is null.</para>
|
||||||
|
/// <para>On failure, <see cref="Plugin"/> and <see cref="LoadContext"/> are null and
|
||||||
|
/// <see cref="Error"/> describes what went wrong.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed record LoadedPlugin(
|
||||||
|
PluginManifest Manifest,
|
||||||
|
IAcDreamPlugin? Plugin,
|
||||||
|
AssemblyLoadContext? LoadContext,
|
||||||
|
Exception? Error)
|
||||||
|
{
|
||||||
|
public bool Success => Plugin is not null && Error is null;
|
||||||
|
}
|
||||||
32
src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs
Normal file
32
src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.Loader;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/AcDream.Core/Plugins/PluginDiscovery.cs
Normal file
38
src/AcDream.Core/Plugins/PluginDiscovery.cs
Normal file
|
|
@ -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<PluginDiscoveryResult> Scan(string pluginsRootDirectory)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(pluginsRootDirectory))
|
||||||
|
return Array.Empty<PluginDiscoveryResult>();
|
||||||
|
|
||||||
|
var results = new List<PluginDiscoveryResult>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/AcDream.Core/Plugins/PluginLoader.cs
Normal file
60
src/AcDream.Core/Plugins/PluginLoader.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Plugins;
|
||||||
|
|
||||||
|
public static class PluginLoader
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Load a plugin DLL from <paramref name="pluginDirectory"/> into a collectible
|
||||||
|
/// <see cref="System.Runtime.Loader.AssemblyLoadContext"/>, find the first type
|
||||||
|
/// implementing <see cref="IAcDreamPlugin"/>, instantiate it, and call its
|
||||||
|
/// <see cref="IAcDreamPlugin.Initialize"/> with the supplied host. Any failure
|
||||||
|
/// is returned as a failed <see cref="LoadedPlugin"/> rather than thrown.
|
||||||
|
/// </summary>
|
||||||
|
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<Type> types;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
types = asm.GetTypes();
|
||||||
|
}
|
||||||
|
catch (ReflectionTypeLoadException rtle)
|
||||||
|
{
|
||||||
|
types = rtle.Types.OfType<Type>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/AcDream.Core/Plugins/PluginManifest.cs
Normal file
71
src/AcDream.Core/Plugins/PluginManifest.cs
Normal file
|
|
@ -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<string> Dependencies)
|
||||||
|
{
|
||||||
|
public static PluginManifest Parse(string json)
|
||||||
|
{
|
||||||
|
PluginManifestDto? dto;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
dto = JsonSerializer.Deserialize<PluginManifestDto>(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<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>? Dependencies { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PluginManifestException : Exception
|
||||||
|
{
|
||||||
|
public PluginManifestException(string message) : base(message) { }
|
||||||
|
public PluginManifestException(string message, Exception inner) : base(message, inner) { }
|
||||||
|
}
|
||||||
50
src/AcDream.Core/Terrain/LandblockMesh.cs
Normal file
50
src/AcDream.Core/Terrain/LandblockMesh.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/AcDream.Core/Terrain/Vertex.cs
Normal file
5
src/AcDream.Core/Terrain/Vertex.cs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Terrain;
|
||||||
|
|
||||||
|
public readonly record struct Vertex(Vector3 Position, Vector3 Normal, Vector2 TexCoord);
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
14
src/AcDream.Plugin.Abstractions/IAcDreamPlugin.cs
Normal file
14
src/AcDream.Plugin.Abstractions/IAcDreamPlugin.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// src/AcDream.Plugin.Abstractions/IAcDreamPlugin.cs
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
public interface IAcDreamPlugin
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Called exactly once, before <see cref="Enable"/>. The plugin should stash the
|
||||||
|
/// host reference and do any one-time setup that doesn't depend on a connected world.
|
||||||
|
/// </summary>
|
||||||
|
void Initialize(IPluginHost host);
|
||||||
|
|
||||||
|
void Enable();
|
||||||
|
void Disable();
|
||||||
|
}
|
||||||
11
src/AcDream.Plugin.Abstractions/IPluginHost.cs
Normal file
11
src/AcDream.Plugin.Abstractions/IPluginHost.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// src/AcDream.Plugin.Abstractions/IPluginHost.cs
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPluginHost
|
||||||
|
{
|
||||||
|
IPluginLogger Log { get; }
|
||||||
|
}
|
||||||
9
src/AcDream.Plugin.Abstractions/IPluginLogger.cs
Normal file
9
src/AcDream.Plugin.Abstractions/IPluginLogger.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
17
src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj
Normal file
17
src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<!-- Plugin DLLs are copied to plugins/<id>/ at build of AcDream.App.
|
||||||
|
They must NOT bring AcDream.Plugin.Abstractions.dll with them;
|
||||||
|
the host already owns it. -->
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj">
|
||||||
|
<Private>false</Private>
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</ProjectReference>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
17
src/AcDream.Plugins.Smoke/SmokePlugin.cs
Normal file
17
src/AcDream.Plugins.Smoke/SmokePlugin.cs
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Private=false + ExcludeAssets=runtime is CRITICAL: prevents the fixture
|
||||||
|
from copying AcDream.Plugin.Abstractions.dll next to itself, which
|
||||||
|
would cause type-identity mismatch when the ALC loads the fixture
|
||||||
|
and the host already has the abstractions loaded. -->
|
||||||
|
<ProjectReference Include="..\..\src\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj">
|
||||||
|
<Private>false</Private>
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</ProjectReference>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
20
tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs
Normal file
20
tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs
Normal file
|
|
@ -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++;
|
||||||
|
}
|
||||||
34
tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj
Normal file
34
tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\AcDream.Core\AcDream.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Build ordering only — do NOT reference at runtime. The tests locate the
|
||||||
|
fixture DLL by path and load it into a separate ALC. -->
|
||||||
|
<ProjectReference Include="..\AcDream.Core.Tests.Fixtures.HelloPlugin\AcDream.Core.Tests.Fixtures.HelloPlugin.csproj">
|
||||||
|
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||||
|
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||||
|
</ProjectReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
91
tests/AcDream.Core.Tests/Plugins/PluginDiscoveryTests.cs
Normal file
91
tests/AcDream.Core.Tests/Plugins/PluginDiscoveryTests.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs
Normal file
102
tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs
Normal file
|
|
@ -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<string>());
|
||||||
|
|
||||||
|
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<string>());
|
||||||
|
|
||||||
|
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<string>());
|
||||||
|
|
||||||
|
var loaded = PluginLoader.Load(coreDllDir, manifest, host);
|
||||||
|
|
||||||
|
Assert.False(loaded.Success);
|
||||||
|
Assert.Contains("IAcDreamPlugin", loaded.Error!.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
tests/AcDream.Core.Tests/Plugins/PluginManifestTests.cs
Normal file
62
tests/AcDream.Core.Tests/Plugins/PluginManifestTests.cs
Normal file
|
|
@ -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<PluginManifestException>(() => PluginManifest.Parse(json));
|
||||||
|
Assert.Equal("missing required field: displayName", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_MalformedJson_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<PluginManifestException>(() => 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
tests/AcDream.Core.Tests/SmokeTest.cs
Normal file
11
tests/AcDream.Core.Tests/SmokeTest.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs
Normal file
76
tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue