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

1915 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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