# Phase 1 — Terrain Rendering + Plugin Scaffold Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Open a Silk.NET OpenGL window and render one AC landblock's terrain heightmap loaded from the retail dats, while standing up the plugin abstractions + host so that a trivial smoke plugin loads on startup and logs a message. **Architecture:** Expand the solution from a single CLI project into four projects: `AcDream.Plugin.Abstractions` (pure interfaces, no deps), `AcDream.Core` (plugin loader + terrain mesh builder), `AcDream.App` (Silk.NET window + render loop + plugin host wiring), and `AcDream.Core.Tests` (xUnit). A fifth tiny project, `AcDream.Plugins.Smoke`, exercises the plugin host end-to-end by building into the runtime `plugins/` folder. Phase 1 ends when the App opens a window, renders one real landblock's terrain, and the smoke plugin shows up in the log. **Tech Stack:** .NET 10, Silk.NET (OpenGL 2.23.0, Windowing), xUnit, Chorizite.DatReaderWriter 2.1.4, Serilog (structured logging). No Avalonia yet — just a raw Silk.NET window. **Context for the engineer:** The repo is at commit `c016ab5` with Phase 0 done (`src/AcDream.Cli/Program.cs` dumps dat asset counts). Read the plugin architecture design at `docs/plans/2026-04-10-plugin-architecture-design.md` before starting. Read the dat research notes in the session memory at `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_research_findings.md`. The retail AC install lives at `references/Asheron's Call/` (gitignored). The user sets `ACDREAM_DAT_DIR` pointing at that folder or passes it as a CLI argument. **Testing philosophy:** TDD the parts that are testable — plugin manifest parsing, plugin discovery, plugin loader (with a real fixture DLL), and landblock mesh generation (pure math on a LandBlock DBObj). Manual smoke test the inherently visual parts — window creation, GL rendering. Manual smoke for rendering is fine; do NOT write GL mock tests. **Commit cadence:** Commit at the end of every task. Never batch tasks. --- ## Task 1: Solution reorg — add the four new projects **Files:** - Create: `src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj` - Create: `src/AcDream.Plugin.Abstractions/Placeholder.cs` (temporary; deleted in Task 4) - Create: `src/AcDream.Core/AcDream.Core.csproj` - Create: `src/AcDream.Core/Placeholder.cs` (temporary; deleted in Task 2) - Create: `src/AcDream.App/AcDream.App.csproj` - Create: `src/AcDream.App/Program.cs` - Create: `tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` - Create: `tests/AcDream.Core.Tests/SmokeTest.cs` - Modify: `AcDream.slnx` (add the four projects) **Step 1: Create class library for plugin abstractions** ```bash dotnet new classlib -n AcDream.Plugin.Abstractions -o src/AcDream.Plugin.Abstractions -f net10.0 ``` Delete the template `Class1.cs` and replace with a one-line placeholder: ```csharp // src/AcDream.Plugin.Abstractions/Placeholder.cs namespace AcDream.Plugin.Abstractions; internal static class Placeholder { } ``` Edit the csproj to enable nullable + implicit usings + latest lang: ```xml net10.0 enable enable latest true ``` **Step 2: Create class library for the core** ```bash dotnet new classlib -n AcDream.Core -o src/AcDream.Core -f net10.0 ``` Delete template `Class1.cs`. Add placeholder: ```csharp // src/AcDream.Core/Placeholder.cs namespace AcDream.Core; internal static class Placeholder { } ``` Same csproj shape as above. Add package references and a project reference to `AcDream.Plugin.Abstractions`: ```xml net10.0 enable enable latest true ``` **Step 3: Create the App console exe** ```bash dotnet new console -n AcDream.App -o src/AcDream.App -f net10.0 ``` Replace `Program.cs` with a minimal entry that will grow: ```csharp // src/AcDream.App/Program.cs Console.WriteLine("acdream app — phase 1 skeleton"); return 0; ``` Edit csproj to reference `AcDream.Core` and add Silk.NET packages: ```xml Exe net10.0 enable enable latest true AcDream.App ``` **Step 4: Create xUnit test project** ```bash dotnet new xunit -n AcDream.Core.Tests -o tests/AcDream.Core.Tests -f net10.0 ``` Delete the template `UnitTest1.cs`. Create a single smoke test that proves the test project wires correctly: ```csharp // tests/AcDream.Core.Tests/SmokeTest.cs namespace AcDream.Core.Tests; public class SmokeTest { [Fact] public void TestProject_IsWired() { Assert.True(true); } } ``` Edit csproj to reference `AcDream.Core`: ```xml ``` **Step 5: Add all four projects to the solution** ```bash dotnet sln add src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj dotnet sln add src/AcDream.Core/AcDream.Core.csproj dotnet sln add src/AcDream.App/AcDream.App.csproj dotnet sln add tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj ``` **Step 6: Build and test — everything green** ```bash dotnet build ``` Expected: `Build succeeded. 0 Warning(s) 0 Error(s)`. ```bash dotnet test ``` Expected: `Passed: 1, Failed: 0`. ```bash dotnet run --project src/AcDream.App ``` Expected: prints `acdream app — phase 1 skeleton` and exits. **Step 7: Commit** ```bash git add src/AcDream.Plugin.Abstractions src/AcDream.Core src/AcDream.App tests/AcDream.Core.Tests AcDream.slnx git commit -m "chore: phase 1 — add Core, Abstractions, App, Tests projects" ``` --- ## Task 2: PluginManifest record + JSON parsing **Files:** - Create: `src/AcDream.Core/Plugins/PluginManifest.cs` - Create: `tests/AcDream.Core.Tests/Plugins/PluginManifestTests.cs` - Delete: `src/AcDream.Core/Placeholder.cs` **Step 1: Write the failing tests** ```csharp // tests/AcDream.Core.Tests/Plugins/PluginManifestTests.cs 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.Contains("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); } } ``` **Step 2: Run tests to verify they fail** ```bash dotnet test --filter FullyQualifiedName~PluginManifestTests ``` Expected: compile errors for `PluginManifest`, `PluginManifestException` — types don't exist. **Step 3: Implement `PluginManifest`** ```csharp // src/AcDream.Core/Plugins/PluginManifest.cs using System.Text.Json; using System.Text.Json.Serialization; 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, nameof(dto.Id)); Require(dto.DisplayName, nameof(dto.DisplayName)); Require(dto.Version, nameof(dto.Version)); Require(dto.EntryDll, nameof(dto.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 fieldName) { if (string.IsNullOrWhiteSpace(value)) throw new PluginManifestException($"missing required field: {char.ToLowerInvariant(fieldName[0])}{fieldName[1..]}"); } 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) { } } ``` Delete the placeholder: `git rm src/AcDream.Core/Placeholder.cs`. **Step 4: Run tests to verify they pass** ```bash dotnet test --filter FullyQualifiedName~PluginManifestTests ``` Expected: `Passed: 4, Failed: 0`. **Step 5: Commit** ```bash git add src/AcDream.Core/Plugins/PluginManifest.cs tests/AcDream.Core.Tests/Plugins/PluginManifestTests.cs git rm src/AcDream.Core/Placeholder.cs git commit -m "feat(core): add PluginManifest json parsing" ``` --- ## Task 3: Plugin discovery — scan a directory **Files:** - Create: `src/AcDream.Core/Plugins/PluginDiscovery.cs` - Create: `tests/AcDream.Core.Tests/Plugins/PluginDiscoveryTests.cs` **Step 1: Write the failing tests** ```csharp // tests/AcDream.Core.Tests/Plugins/PluginDiscoveryTests.cs 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); } } ``` **Step 2: Run tests to verify they fail** ```bash dotnet test --filter FullyQualifiedName~PluginDiscoveryTests ``` Expected: compile errors for `PluginDiscovery`. **Step 3: Implement `PluginDiscovery`** ```csharp // src/AcDream.Core/Plugins/PluginDiscovery.cs namespace AcDream.Core.Plugins; public sealed record PluginDiscoveryResult( string PluginDirectory, PluginManifest? Manifest, string? 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)) { 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.Message)); } } return results; } } ``` **Step 4: Run tests to verify they pass** ```bash dotnet test --filter FullyQualifiedName~PluginDiscoveryTests ``` Expected: `Passed: 5, Failed: 0`. **Step 5: Commit** ```bash git add src/AcDream.Core/Plugins/PluginDiscovery.cs tests/AcDream.Core.Tests/Plugins/PluginDiscoveryTests.cs git commit -m "feat(core): add PluginDiscovery directory scan" ``` --- ## Task 4: Plugin abstractions — minimal interfaces **Files:** - Create: `src/AcDream.Plugin.Abstractions/IAcDreamPlugin.cs` - Create: `src/AcDream.Plugin.Abstractions/IPluginHost.cs` - Create: `src/AcDream.Plugin.Abstractions/ILogger.cs` - Delete: `src/AcDream.Plugin.Abstractions/Placeholder.cs` **No TDD for pure interfaces.** Interfaces are type declarations, not behavior. Tests get added when the host wires up real implementations (Task 5+). **Step 1: Write the interfaces** ```csharp // src/AcDream.Plugin.Abstractions/IAcDreamPlugin.cs namespace AcDream.Plugin.Abstractions; public interface IAcDreamPlugin { void Initialize(IPluginHost host); void Enable(); void Disable(); } ``` ```csharp // 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 ILogger is real. /// public interface IPluginHost { ILogger Log { get; } } ``` ```csharp // src/AcDream.Plugin.Abstractions/ILogger.cs namespace AcDream.Plugin.Abstractions; public interface ILogger { void Info(string message); void Warn(string message); void Error(string message, Exception? exception = null); } ``` Delete the placeholder: `git rm src/AcDream.Plugin.Abstractions/Placeholder.cs`. **Step 2: Build — verify abstractions compile** ```bash dotnet build src/AcDream.Plugin.Abstractions ``` Expected: `Build succeeded. 0 Warning(s) 0 Error(s)`. **Step 3: Commit** ```bash git add src/AcDream.Plugin.Abstractions git rm src/AcDream.Plugin.Abstractions/Placeholder.cs git commit -m "feat(abstractions): add IAcDreamPlugin, IPluginHost, ILogger" ``` --- ## Task 5: PluginLoader — load DLL into collectible ALC and instantiate **Files:** - Create: `src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs` - Create: `src/AcDream.Core/Plugins/PluginLoader.cs` - Create: `src/AcDream.Core/Plugins/LoadedPlugin.cs` - Create: `tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs` - Create: `tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj` - Create: `tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs` **Fixture plugin project.** To test loading a real DLL we need a real plugin DLL. We build one in the solution under `tests/AcDream.Core.Tests.Fixtures.HelloPlugin/`. It's not referenced by the test project — instead the test project reads its build output directly from `tests/AcDream.Core.Tests.Fixtures.HelloPlugin/bin/$(Configuration)/net10.0/`. The test project's csproj has a BeforeTargets hook to ensure the fixture builds first. **Step 1: Create the fixture plugin** ```bash dotnet new classlib -n AcDream.Core.Tests.Fixtures.HelloPlugin -o tests/AcDream.Core.Tests.Fixtures.HelloPlugin -f net10.0 ``` Delete the template `Class1.cs`. Replace csproj contents: ```xml net10.0 enable enable latest false false runtime ``` (The `Private=false` + `ExcludeAssets=runtime` ensures the fixture plugin does NOT copy `AcDream.Plugin.Abstractions.dll` next to itself — critical, because the host supplies that assembly from the default ALC; having a second copy would break type identity.) Create the plugin class: ```csharp // tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs 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++; } ``` Add to solution: ```bash dotnet sln add tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj ``` Add the fixture as a build-order dependency (but NOT a reference) on the test project. Edit `tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` to add: ```xml false true ``` **Step 2: Write the failing tests** ```csharp // tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs 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 ILogger Log { get; } = new StubLogger(); } private sealed class StubLogger : ILogger { 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 ?? ""); } } ``` **Step 3: Run tests to verify they fail** ```bash dotnet test --filter FullyQualifiedName~PluginLoaderTests ``` Expected: compile errors for `PluginLoader`, `LoadedPlugin`. **Step 4: Implement the loader** ```csharp // src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs 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 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 == "AcDream.Plugin.Abstractions") return null; var path = _resolver.ResolveAssemblyToPath(assemblyName); return path is null ? null : LoadFromAssemblyPath(path); } } ``` ```csharp // src/AcDream.Core/Plugins/LoadedPlugin.cs using AcDream.Plugin.Abstractions; namespace AcDream.Core.Plugins; public sealed record LoadedPlugin( PluginManifest Manifest, IAcDreamPlugin? Plugin, string? Error) { public bool Success => Plugin is not null && Error is null; } ``` ```csharp // src/AcDream.Core/Plugins/PluginLoader.cs using System.Reflection; using AcDream.Plugin.Abstractions; namespace AcDream.Core.Plugins; public static class PluginLoader { 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, null, $"entry dll not found: {dllPath}"); try { var alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath); var asm = alc.LoadFromAssemblyPath(dllPath); var pluginType = asm.GetTypes() .FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t)); if (pluginType is null) return new LoadedPlugin(manifest, null, $"no IAcDreamPlugin implementation found in {manifest.EntryDll}"); var instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; instance.Initialize(host); return new LoadedPlugin(manifest, instance, null); } catch (Exception ex) { return new LoadedPlugin(manifest, null, $"{ex.GetType().Name}: {ex.Message}"); } } } ``` **Step 5: Run tests to verify they pass** ```bash dotnet test --filter FullyQualifiedName~PluginLoaderTests ``` Expected: `Passed: 3, Failed: 0`. **Step 6: Commit** ```bash git add src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs src/AcDream.Core/Plugins/PluginLoader.cs src/AcDream.Core/Plugins/LoadedPlugin.cs tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs tests/AcDream.Core.Tests.Fixtures.HelloPlugin tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj AcDream.slnx git commit -m "feat(core): add PluginLoader with collectible ALC" ``` --- ## Task 6: LandBlock mesh generation **Context:** An AC LandBlock is 9×9 heightmap vertices covering an 8×8 grid of cells. Each cell is 24.0 world units wide (192.0 / 8). Height values are byte-scaled — multiply by `2.0f` to get world units. Each cell becomes 2 triangles, so one landblock = 128 triangles, 81 vertices. **Files:** - Create: `src/AcDream.Core/Terrain/LandblockMesh.cs` - Create: `src/AcDream.Core/Terrain/Vertex.cs` - Create: `tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs` **Step 1: Write the failing tests** ```csharp // tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs 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); } } ``` **Step 2: Run tests to verify they fail** ```bash dotnet test --filter FullyQualifiedName~LandblockMeshTests ``` Expected: compile errors for `LandblockMesh`, `Vertex`. **Step 3: Implement** ```csharp // src/AcDream.Core/Terrain/Vertex.cs using System.Numerics; namespace AcDream.Core.Terrain; public readonly record struct Vertex(Vector3 Position, Vector3 Normal, Vector2 TexCoord); ``` ```csharp // src/AcDream.Core/Terrain/LandblockMesh.cs 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); } } ``` **Step 4: Run tests to verify they pass** ```bash dotnet test --filter FullyQualifiedName~LandblockMeshTests ``` Expected: `Passed: 4, Failed: 0`. **Step 5: Commit** ```bash git add src/AcDream.Core/Terrain/LandblockMesh.cs src/AcDream.Core/Terrain/Vertex.cs tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs git commit -m "feat(core): add LandblockMesh flat-terrain generator" ``` --- ## Task 7: Silk.NET window opens and clears to a color **Files:** - Modify: `src/AcDream.App/Program.cs` - Create: `src/AcDream.App/Rendering/GameWindow.cs` **No automated test.** This is a visual smoke test. Acceptance: running the app opens a window, shows a dark blue clear color, and closes cleanly on window close or Escape. **Step 1: Replace `Program.cs`** ```csharp // src/AcDream.App/Program.cs using AcDream.App.Rendering; var window = new GameWindow(); window.Run(); return 0; ``` **Step 2: Implement `GameWindow`** ```csharp // src/AcDream.App/Rendering/GameWindow.cs 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 IWindow? _window; private GL? _gl; private IInputContext? _input; 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(); }; _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); } private void OnRender(double deltaSeconds) { _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); } private void OnClosing() { _input?.Dispose(); _gl?.Dispose(); } public void Dispose() => _window?.Dispose(); } ``` **Step 3: Build and run — manual smoke test** ```bash dotnet run --project src/AcDream.App ``` Expected: a 1280×720 window titled "acdream — phase 1" opens with a dark navy blue background. Pressing Escape or closing the window exits cleanly. **Step 4: Commit** ```bash git add src/AcDream.App/Program.cs src/AcDream.App/Rendering/GameWindow.cs git commit -m "feat(app): Silk.NET window smoke — clear to navy" ``` --- ## Task 8: Load one landblock from dats and upload to GPU **Context:** We'll hardcode a single landblock ID. Holtburg town center is at landblock coordinate `0xA9B4`, so the LandBlock heightmap ID is `0xA9B4FFFF`. If that fails to load, any landblock ID ending in `FFFF` from the user's `client_cell_1.dat` will do. **Files:** - Modify: `src/AcDream.App/Program.cs` - Modify: `src/AcDream.App/Rendering/GameWindow.cs` - Create: `src/AcDream.App/Rendering/TerrainRenderer.cs` **Step 1: Wire dat path into `Program.cs`** ```csharp // src/AcDream.App/Program.cs using AcDream.App.Rendering; var datDir = args.FirstOrDefault() ?? Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); if (string.IsNullOrWhiteSpace(datDir)) { Console.Error.WriteLine("usage: AcDream.App "); Console.Error.WriteLine(" or: set ACDREAM_DAT_DIR and run with no args"); return 2; } using var window = new GameWindow(datDir); window.Run(); return 0; ``` **Step 2: Extend `GameWindow` to own a `TerrainRenderer`** Add field, constructor, and render call. Load the LandBlock DBObj and pass it + the GL instance to the renderer in `OnLoad`: ```csharp // additions to GameWindow.cs using AcDream.Core.Terrain; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Options; // ... private readonly string _datDir; private TerrainRenderer? _terrain; private DatCollection? _dats; public GameWindow(string datDir) => _datDir = datDir; 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(); }; _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); _gl.Enable(EnableCap.DepthTest); _dats = new DatCollection(_datDir, DatAccessType.Read); // Find ANY landblock ending in 0xFFFF. The Holtburg block 0xA9B4FFFF is a // good default; fall back to the first one we find. uint landblockId = 0xA9B4FFFFu; LandBlock? block = null; if (!_dats.Cell.TryGet(landblockId, out block)) { foreach (var file in _dats.Cell.Tree) { if ((file.Id & 0xFFFFu) == 0xFFFFu) { landblockId = file.Id; _dats.Cell.TryGet(landblockId, out block); 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); } private void OnRender(double deltaSeconds) { _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); _terrain?.Draw(); } private void OnClosing() { _terrain?.Dispose(); _dats?.Dispose(); _input?.Dispose(); _gl?.Dispose(); } ``` **Step 3: Create `TerrainRenderer` stub that owns VBO/EBO/VAO** (Shader + actual draw come in Task 9; for this task we just upload buffers and hold the handles.) ```csharp // src/AcDream.App/Rendering/TerrainRenderer.cs using AcDream.Core.Terrain; using Silk.NET.OpenGL; namespace AcDream.App.Rendering; public sealed unsafe class TerrainRenderer : IDisposable { private readonly GL _gl; private readonly uint _vao; private readonly uint _vbo; private readonly uint _ebo; private readonly int _indexCount; public TerrainRenderer(GL gl, LandblockMeshData meshData) { _gl = gl; _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() { // Shader binding + draw call come in Task 9. For this task, binding the // VAO is enough to prove the buffers uploaded without GL errors. _gl.BindVertexArray(_vao); // intentionally no draw call yet _gl.BindVertexArray(0); } public void Dispose() { _gl.DeleteBuffer(_vbo); _gl.DeleteBuffer(_ebo); _gl.DeleteVertexArray(_vao); } } ``` **Step 4: Build + manual smoke** ```bash dotnet run --project src/AcDream.App -- "references/Asheron's Call" ``` Expected: window opens, still dark blue (no draw yet), console prints `loaded landblock 0x....FFFF`, no GL errors. **Step 5: Commit** ```bash git add src/AcDream.App/Program.cs src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/Rendering/TerrainRenderer.cs git commit -m "feat(app): load landblock from dats and upload mesh to GPU" ``` --- ## Task 9: Draw the terrain with a simple height-ramp shader + orbit camera **Files:** - Modify: `src/AcDream.App/Rendering/TerrainRenderer.cs` - Create: `src/AcDream.App/Rendering/Shader.cs` - Create: `src/AcDream.App/Rendering/OrbitCamera.cs` - Create: `src/AcDream.App/Rendering/Shaders/terrain.vert` - Create: `src/AcDream.App/Rendering/Shaders/terrain.frag` - Modify: `src/AcDream.App/AcDream.App.csproj` (copy shaders to output) - Modify: `src/AcDream.App/Rendering/GameWindow.cs` (own camera, pass view/projection) **Step 1: Add shader files and make them copy to output** ```glsl // src/AcDream.App/Rendering/Shaders/terrain.vert #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); } ``` ```glsl // src/AcDream.App/Rendering/Shaders/terrain.frag #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); } ``` Add to `AcDream.App.csproj`: ```xml PreserveNewest ``` **Step 2: Implement `Shader` helper** ```csharp // src/AcDream.App/Rendering/Shader.cs 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); } ``` **Step 3: Implement `OrbitCamera`** ```csharp // src/AcDream.App/Rendering/OrbitCamera.cs 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); } ``` **Step 4: Update `TerrainRenderer.Draw` to actually draw** ```csharp // modifications to src/AcDream.App/Rendering/TerrainRenderer.cs // Constructor: accept a Shader // Draw(): bind shader, set uniforms, bind VAO, DrawElements 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; // ... existing VAO/VBO/EBO setup ... } 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); } // ... Dispose unchanged ... } ``` **Step 5: Update `GameWindow` to own shader + camera, pass camera to renderer** ```csharp // additions / edits to GameWindow.cs private Shader? _shader; private OrbitCamera? _camera; private void OnLoad() { // ... existing init ... 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, }; // ... load landblock and build mesh as before ... _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(); } ``` Also hook up mouse drag to orbit camera yaw/pitch, and scroll wheel to distance. Add in `OnLoad` after input creation: ```csharp 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); } ``` Add corresponding `private float _lastMouseX, _lastMouseY;` fields. **Step 6: Build + manual smoke** ```bash dotnet run --project src/AcDream.App -- "references/Asheron's Call" ``` Expected: window opens showing a green/brown/white terrain patch (one landblock) from an orbit view. Left-drag rotates, scroll wheel zooms, Escape closes. **Step 7: Commit** ```bash git add src/AcDream.App/Rendering src/AcDream.App/AcDream.App.csproj git commit -m "feat(app): render landblock with height-ramp shader + orbit camera" ``` --- ## Task 10: Wire plugin host into AcDream.App, ship smoke plugin, verify log **Files:** - Create: `src/AcDream.App/Plugins/AppPluginHost.cs` - Create: `src/AcDream.App/Plugins/SerilogAdapter.cs` - Modify: `src/AcDream.App/Program.cs` (init Serilog + plugin host before window) - Create: `plugins/AcDream.Plugins.Smoke/plugin.json` - Create: `src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj` - Create: `src/AcDream.Plugins.Smoke/SmokePlugin.cs` - Modify: `AcDream.slnx` (add smoke project) **Step 1: Create the smoke plugin project** ```bash dotnet new classlib -n AcDream.Plugins.Smoke -o src/AcDream.Plugins.Smoke -f net10.0 ``` Delete `Class1.cs`. csproj: ```xml net10.0 enable enable latest false runtime ``` ```csharp // src/AcDream.Plugins.Smoke/SmokePlugin.cs 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"); } ``` Add to solution: ```bash dotnet sln add src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj ``` **Step 2: Create the plugin folder layout in the App output** Add to `src/AcDream.App/AcDream.App.csproj` inside its first ``: ```xml false true <_SmokePluginSourceDir>..\AcDream.Plugins.Smoke\bin\$(Configuration)\net10.0 <_SmokePluginDestDir>$(OutputPath)plugins\AcDream.Plugins.Smoke ``` **Step 3: Implement `AppPluginHost` and `SerilogAdapter`** ```csharp // src/AcDream.App/Plugins/SerilogAdapter.cs using AcDream.Plugin.Abstractions; using Serilog; namespace AcDream.App.Plugins; public sealed class SerilogAdapter : ILogger { 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); } ``` ```csharp // src/AcDream.App/Plugins/AppPluginHost.cs using AcDream.Plugin.Abstractions; namespace AcDream.App.Plugins; public sealed class AppPluginHost : IPluginHost { public AppPluginHost(ILogger log) => Log = log; public ILogger Log { get; } } ``` **Step 4: Update `Program.cs` to init logging + discover + load plugins** ```csharp // src/AcDream.App/Program.cs 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); } foreach (var plugin in loaded) plugin.Plugin!.Enable(); try { 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; ``` **Step 5: Build and manual smoke** ```bash dotnet build dotnet run --project src/AcDream.App -- "references/Asheron's Call" ``` Expected console output includes lines similar to: ``` [INF] scanning plugins in ...\bin\Debug\net10.0\plugins [INF] loaded plugin acdream.smoke (Smoke Plugin) [INF] smoke plugin initialized [INF] smoke plugin enabled loaded landblock 0x....FFFF ``` Window opens showing terrain. Closing window logs `smoke plugin disabled`. **Step 6: Run all tests once more** ```bash dotnet test ``` Expected: all tests pass, no regressions. **Step 7: Commit** ```bash git add src/AcDream.App src/AcDream.Plugins.Smoke AcDream.slnx git commit -m "feat(app): wire plugin host, ship smoke plugin, log lifecycle" ``` --- ## Phase 1 — done criteria Phase 1 is complete when all of the following are true on a clean checkout: 1. `dotnet build` — clean, no warnings. 2. `dotnet test` — all tests pass (expected count: 4 + 5 + 3 + 4 + 1 = 17). 3. `dotnet run --project src/AcDream.App -- "references/Asheron's Call"` opens a 1280×720 window showing a recognizable green/brown/white terrain patch for one real landblock. Left-drag rotates the camera, scroll zooms, Escape closes. 4. Console shows `loaded plugin acdream.smoke (Smoke Plugin)`, `smoke plugin initialized`, `smoke plugin enabled` on startup, and `smoke plugin disabled` on shutdown. 5. `git log --oneline` shows 10 new commits after `c016ab5`, one per task. ## Intentional non-goals for Phase 1 - No texturing of terrain surfaces (they're flat colored by height). - No neighboring landblocks (only one is rendered). - No static meshes on the landblock (GfxObj rendering is Phase 2). - No WASD/first-person camera (orbit only). - No Avalonia UI chrome (raw Silk.NET window). - No real plugin API surface beyond `ILogger` — Phase 2 and later fill in `IOverlay`, `IGameState`, `IEvents`, `IPacketPipeline`. - No hot reload of plugins. - No parsing of `block.Terrain[i].Type` (terrain texture index) — we only use the heightmap. ## Relevant skills to invoke during execution - `superpowers:executing-plans` — to walk through this plan task by task - `superpowers:test-driven-development` — for Tasks 2, 3, 5, 6 (the testable ones) - `superpowers:systematic-debugging` — if a task fails unexpectedly - `superpowers:verification-before-completion` — before marking each task complete