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

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

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

```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
<ItemGroup>
  <ProjectReference Include="..\..\src\AcDream.Core\AcDream.Core.csproj" />
</ItemGroup>
```

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

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

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

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

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

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

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

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

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

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

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

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

```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
<ItemGroup>
  <None Update="Rendering\Shaders\*.*">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </None>
</ItemGroup>
```

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

```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 `<ItemGroup>`:

```xml
<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`**

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

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