acdream/docs/plans/2026-04-10-phase-1-terrain-and-plugin-scaffold.md
Erik 84c76ba6aa docs: phase 1 implementation plan
Ten bite-sized tasks covering: solution reorg into four projects,
TDD-driven PluginManifest parsing + discovery + collectible-ALC
loader (with a real fixture DLL), LandblockMesh pure generator,
Silk.NET window smoke, dat-backed landblock mesh upload, height-
ramp shader + orbit camera, and finally the end-to-end smoke
plugin round-trip. Manual visual smoke only for the GL bits;
xUnit TDD for everything testable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:17:49 +02:00

57 KiB
Raw Blame History

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

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:

// src/AcDream.Plugin.Abstractions/Placeholder.cs
namespace AcDream.Plugin.Abstractions;
internal static class Placeholder { }

Edit the csproj to enable nullable + implicit usings + latest lang:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <LangVersion>latest</LangVersion>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>
</Project>

Step 2: Create class library for the core

dotnet new classlib -n AcDream.Core -o src/AcDream.Core -f net10.0

Delete template Class1.cs. Add placeholder:

// 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:

<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>

Step 3: Create the App console exe

dotnet new console -n AcDream.App -o src/AcDream.App -f net10.0

Replace Program.cs with a minimal entry that will grow:

// 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:

<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>
  </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>
</Project>

Step 4: Create xUnit test project

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:

// 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:

<ItemGroup>
  <ProjectReference Include="..\..\src\AcDream.Core\AcDream.Core.csproj" />
</ItemGroup>

Step 5: Add all four projects to the solution

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

dotnet build

Expected: Build succeeded. 0 Warning(s) 0 Error(s).

dotnet test

Expected: Passed: 1, Failed: 0.

dotnet run --project src/AcDream.App

Expected: prints acdream app — phase 1 skeleton and exits.

Step 7: Commit

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

// 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<PluginManifestException>(() => PluginManifest.Parse(json));
        Assert.Contains("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);
    }
}

Step 2: Run tests to verify they fail

dotnet test --filter FullyQualifiedName~PluginManifestTests

Expected: compile errors for PluginManifest, PluginManifestException — types don't exist.

Step 3: Implement PluginManifest

// 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<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, 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<string>());
    }

    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<string>? 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

dotnet test --filter FullyQualifiedName~PluginManifestTests

Expected: Passed: 4, Failed: 0.

Step 5: Commit

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

// 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

dotnet test --filter FullyQualifiedName~PluginDiscoveryTests

Expected: compile errors for PluginDiscovery.

Step 3: Implement PluginDiscovery

// 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<PluginDiscoveryResult> Scan(string pluginsRootDirectory)
    {
        if (!Directory.Exists(pluginsRootDirectory))
            return Array.Empty<PluginDiscoveryResult>();

        var results = new List<PluginDiscoveryResult>();
        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

dotnet test --filter FullyQualifiedName~PluginDiscoveryTests

Expected: Passed: 5, Failed: 0.

Step 5: Commit

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

// src/AcDream.Plugin.Abstractions/IAcDreamPlugin.cs
namespace AcDream.Plugin.Abstractions;

public interface IAcDreamPlugin
{
    void Initialize(IPluginHost host);
    void Enable();
    void Disable();
}
// 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 ILogger is real.
/// </summary>
public interface IPluginHost
{
    ILogger Log { get; }
}
// 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

dotnet build src/AcDream.Plugin.Abstractions

Expected: Build succeeded. 0 Warning(s) 0 Error(s).

Step 3: Commit

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

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:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <LangVersion>latest</LangVersion>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\src\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj">
      <Private>false</Private>
      <ExcludeAssets>runtime</ExcludeAssets>
    </ProjectReference>
  </ItemGroup>
</Project>

(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:

// 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:

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:

<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>

Step 2: Write the failing tests

// 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<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 ?? "");
    }
}

Step 3: Run tests to verify they fail

dotnet test --filter FullyQualifiedName~PluginLoaderTests

Expected: compile errors for PluginLoader, LoadedPlugin.

Step 4: Implement the loader

// src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs
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 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);
    }
}
// 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;
}
// 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

dotnet test --filter FullyQualifiedName~PluginLoaderTests

Expected: Passed: 3, Failed: 0.

Step 6: Commit

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

// 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

dotnet test --filter FullyQualifiedName~LandblockMeshTests

Expected: compile errors for LandblockMesh, Vertex.

Step 3: Implement

// src/AcDream.Core/Terrain/Vertex.cs
using System.Numerics;

namespace AcDream.Core.Terrain;

public readonly record struct Vertex(Vector3 Position, Vector3 Normal, Vector2 TexCoord);
// 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

dotnet test --filter FullyQualifiedName~LandblockMeshTests

Expected: Passed: 4, Failed: 0.

Step 5: Commit

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

// src/AcDream.App/Program.cs
using AcDream.App.Rendering;

var window = new GameWindow();
window.Run();
return 0;

Step 2: Implement GameWindow

// 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<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();
            };

        _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

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

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

// 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 <dat-directory>");
    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:

// 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.)

// 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

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

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

// 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);
}
// 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:

<ItemGroup>
  <None Update="Rendering\Shaders\*.*">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </None>
</ItemGroup>

Step 2: Implement Shader helper

// 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

// 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

// 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

// 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:

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

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

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

dotnet new classlib -n AcDream.Plugins.Smoke -o src/AcDream.Plugins.Smoke -f net10.0

Delete Class1.cs. csproj:

<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>
// 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:

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 <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="{ &quot;id&quot;: &quot;acdream.smoke&quot;, &quot;displayName&quot;: &quot;Smoke Plugin&quot;, &quot;version&quot;: &quot;0.1.0&quot;, &quot;entryDll&quot;: &quot;AcDream.Plugins.Smoke.dll&quot;, &quot;apiVersion&quot;: 1 }" />
</Target>

Step 3: Implement AppPluginHost and SerilogAdapter

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

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

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

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

dotnet test

Expected: all tests pass, no regressions.

Step 7: Commit

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