diff --git a/docs/plans/2026-04-10-phase-2b-design.md b/docs/plans/2026-04-10-phase-2b-design.md new file mode 100644 index 0000000..bf36ca4 --- /dev/null +++ b/docs/plans/2026-04-10-phase-2b-design.md @@ -0,0 +1,448 @@ +# acdream Phase 2b design: terrain atlas, neighbor landblocks, dual cameras, plugin API growth + +**Date:** 2026-04-10 +**Status:** Approved — ready for implementation planning. +**Builds on:** Phase 2a (merged as `1d1e668` + fix-up `cc55c3f` on `main`). + +## Goal + +Turn Phase 2a's textured-but-single Holtburg landblock into a 3×3 neighbor grid rendered with real terrain textures from the retail dats, explorable with a first-person fly camera (F to toggle), and expose the loaded world entity list to plugins via a minimal but complete `IGameState` + `IEvents` surface that a plugin subscribing late still sees every entity exactly once. + +## Non-goals + +- Lighting on terrain or meshes. Flat shading only. +- Terrain texture blending at cell boundaries. Sharp edges between grass and dirt are acceptable; the `flat` interpolation qualifier on the terrain layer attribute enforces this. +- Frustum culling. 9 landblocks × 126 static entities is small enough the GPU doesn't care. +- Collision for the fly camera. Walking through walls is expected. +- Dynamic entity spawns. All entities are loaded once at world load; `IGameState.Entities` is built once and doesn't mutate. +- Animated doors / windows / NPCs. Phase 3+ concern. +- Loading more than one 3×3 grid. The center stays hardcoded at Holtburg 0xA9B4FFFF. + +## Locked decisions (from brainstorming) + +1. **Full scope:** all 8 sketched Phase 2b tasks (11–18 in the original Phase 2 plan) in one session. +2. **`GL_TEXTURE_2D_ARRAY`** for terrain textures. Per-vertex `aTerrainLayer` attribute indexes the layer. +3. **Raw cursor capture** for `FlyCamera` (`CursorMode.Raw`), WASD + Space/Ctrl movement, F toggles between orbit and fly, Escape releases cursor (fly → orbit; orbit → close window). +4. **EntitySpawned fires during world load**, `WorldEvents` implements **replay-on-subscribe** so a plugin subscribing in `Enable()` sees every already-spawned entity before returning. + +## Architecture overview + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ acdream client │ +│ │ +│ dat directory │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ AcDream.Core │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ Terrain (P2a │ │ World (P2a) │ │ Textures(P2a)│ │ │ +│ │ │ updated) │ │ │ │ │ │ │ +│ │ │ │ │ WorldView │ │ SurfaceDec. │ │ │ +│ │ │ Vertex gains │ │ LandblockLdr │ │ (no change) │ │ │ +│ │ │ TerrainLayer │ │ WorldEntity │ │ │ │ │ +│ │ │ LandblockMesh│ │ │ │ │ │ │ +│ │ │ takes layer │ │ │ │ │ │ │ +│ │ │ map │ │ │ │ │ │ │ +│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ +│ └──────────────┬─────────────┬─────────────────┬────────────────────────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ AcDream.App / Rendering │ │ +│ │ │ │ +│ │ TerrainRenderer(updated) StaticMeshRenderer (no change) │ │ +│ │ ↳ per-landblock uModel │ │ +│ │ ↳ samples sampler2DArray │ │ +│ │ │ │ +│ │ TerrainAtlas(new) │ │ +│ │ ↳ builds GL_TEXTURE_2D_ARRAY at load │ │ +│ │ ↳ Dictionary │ │ +│ │ │ │ +│ │ Shader: terrain.vert/frag (rewritten) │ │ +│ │ ↳ new attribute: flat uint aTerrainLayer │ │ +│ │ ↳ frag: texture(uAtlas, vec3(uv, float(vLayer))) │ │ +│ │ │ │ +│ │ ICamera(new) │ │ +│ │ ├── OrbitCamera (refactored: implements ICamera) │ │ +│ │ └── FlyCamera (new: WASD+mouse, raw cursor) │ │ +│ │ CameraController (F toggle, Escape contextual) │ │ +│ └────────────────┬─────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Plugin pipeline — Phase 2b growth │ │ +│ │ │ │ +│ │ abstractions: │ │ +│ │ IGameState.Entities (snapshot, immutable) │ │ +│ │ IEvents.EntitySpawned (fires+replays on subscribe) │ │ +│ │ WorldEntitySnapshot (record struct) │ │ +│ │ │ │ +│ │ app-side: │ │ +│ │ WorldGameState : IGameState │ │ +│ │ WorldEvents : IEvents + FireEntitySpawned() │ │ +│ │ AppPluginHost : Log + State + Events │ │ +│ │ │ │ +│ │ SmokePlugin subscribes in Enable(), logs replay count │ │ +│ └────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +**Key principles:** + +1. Phase 2a's architecture stays intact. The only Phase 2a change is `LandblockMesh.Build` emitting a per-vertex terrain type index alongside position/normal/uv, plus `Vertex` growing a `TerrainLayer` field. `StaticMeshRenderer` inherits the new vertex stride for free because the mesh shader ignores `TerrainLayer` for static meshes (layer=0 by convention). +2. `WorldView` becomes the entry point for terrain in `GameWindow`. Returns 9 landblocks + their entities. +3. Neighbor translation is done via a `uModel` uniform per landblock, not baked into vertex positions. Keeps mesh data compact and shareable. +4. Terrain atlas built once at load from the unique set of terrain type bytes seen in any loaded landblock. Cheap to compute (729 checks). +5. `ICamera` is a pure interface. Both cameras implement it. `CameraController` owns both and exposes the active one. +6. `WorldGameState.Entities` is a frozen `IReadOnlyList` built once at world load. +7. `WorldEvents` provides **replay-on-subscribe** so plugin ordering doesn't matter — any plugin that subscribes at any time sees every entity exactly once. + +## Terrain atlas + updated mesh + +### Terrain-type resolution + +`LandBlock.Terrain[]` is 81 `TerrainInfo` ushorts. The low bits encode terrain type as an enum (grass, dirt, forest, snow, etc., typically 0..31). For Phase 2b we only use the low 5 bits: + +```csharp +byte terrainType = (byte)((ushort)block.Terrain[i] & 0x1F); +``` + +The mapping from terrain type to a texture `SurfaceTexture` id lives in `Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc` — a list where each entry maps a terrain type enum to a `QualifiedDataId`. At load time we walk this list to build a `Dictionary` of `terrainType → SurfaceTextureId`, then resolve each surface texture → RenderSurface → decoded RGBA8 → layer in the GL array texture. + +(If the exact DatReaderWriter field path differs from the above at implementation time, the spec is wrong on the path; the algorithm is correct. Implementer should follow the real type shape.) + +### `Vertex` struct grows + +```csharp +public readonly record struct Vertex( + Vector3 Position, + Vector3 Normal, + Vector2 TexCoord, + uint TerrainLayer); +``` + +Stride becomes 36 bytes. Both `TerrainRenderer` and `StaticMeshRenderer` update their `VertexAttribPointer` calls to include the new attribute at location 3. For static meshes, `GfxObjMesh.Build` writes `TerrainLayer = 0` (the value is ignored by the mesh shader). For terrain, `LandblockMesh.Build` writes the per-vertex atlas layer index. + +### `LandblockMesh.Build` signature grows + +```csharp +public static LandblockMeshData Build( + LandBlock block, + float[] heightTable, + IReadOnlyDictionary terrainTypeToLayer); +``` + +The third parameter maps raw terrain-type bytes (extracted from `block.Terrain[i]` low 5 bits) to atlas layer indices. If a terrain type isn't in the map, the mesh uses layer 0 as a fallback. + +The x-major heightmap indexing fix from `cc55c3f` remains. + +UV scaling changes: Phase 1 used `TexCoord = (x, y) / 8` (one texture stretched across the landblock). Phase 2b uses `TexCoord = (x, y) / 2` so one terrain texture tiles ~4× per landblock, which looks more natural at typical camera distances. + +### `TerrainAtlas` class + +```csharp +public sealed class TerrainAtlas : IDisposable +{ + public uint GlTexture { get; } + public IReadOnlyDictionary TerrainTypeToLayer { get; } + public int LayerCount { get; } + + public static TerrainAtlas Build( + GL gl, + DatCollection dats, + IEnumerable loadedLandblocks); + + public void Dispose(); +} +``` + +`Build` walks all vertices of all loaded landblocks to collect the unique terrain types, resolves each to a `SurfaceTexture` → `RenderSurface` → decoded RGBA8 via `SurfaceDecoder`, allocates a GL_TEXTURE_2D_ARRAY of the max (width, height) at layer 0 and uploads each decoded texture to its own layer. Returns the handle and the layer map. + +### Shader updates + +`terrain.vert`: + +```glsl +#version 430 core +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTex; +layout(location = 3) in uint aTerrainLayer; + +uniform mat4 uModel; +uniform mat4 uView; +uniform mat4 uProjection; + +out vec2 vTex; +out flat uint vLayer; + +void main() { + vTex = aTex; + vLayer = aTerrainLayer; + gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0); +} +``` + +`terrain.frag`: + +```glsl +#version 430 core +in vec2 vTex; +in flat uint vLayer; +out vec4 fragColor; + +uniform sampler2DArray uAtlas; + +void main() { + fragColor = texture(uAtlas, vec3(vTex, float(vLayer))); +} +``` + +Integer vertex attributes must be `flat`-interpolated in GLSL, so boundaries between cells with different terrain types are sharp. Blending is Phase 3+. + +### `TerrainRenderer` updates + +Now owns a `TerrainAtlas` reference and a list of `(LandblockMeshData mesh, Vector3 worldOrigin)` pairs (one per loaded landblock). `Draw(camera)` binds the atlas once, then for each landblock sets `uModel = Translation(worldOrigin)`, binds that landblock's VAO, and issues the draw call. + +Neighbor landblock world origin calc: for the center at `(cx, cy)`, a neighbor at `(cx + dx, cy + dy)` has origin `(dx * 192, dy * 192, 0)` in world space (the center landblock is at the origin). The orbit camera's default target stays at `(96, 96, 0)` which is the center of the center landblock. + +## Dual cameras + +### `ICamera` interface + +```csharp +public interface ICamera +{ + Matrix4x4 View { get; } + Matrix4x4 Projection { get; } + float Aspect { get; set; } +} +``` + +### `OrbitCamera` refactor + +Existing class gains `: ICamera`. Zero behavioral change. `Aspect` already has a setter. All Phase 1/2 tests still pass. + +### `FlyCamera` + +```csharp +public sealed class FlyCamera : ICamera +{ + public Vector3 Position { get; set; } = new(96, 96, 150); + public float Yaw { get; set; } = MathF.PI / 2; + public float Pitch { get; set; } = -0.3f; + public float FovY { get; set; } = MathF.PI / 3f; + public float Aspect { get; set; } = 16f / 9f; + + public float MoveSpeed { get; set; } = 100f; + public float MouseSensitivity { get; set; } = 0.003f; + + public Matrix4x4 View { get; } + public Matrix4x4 Projection { get; } + + public void Update(double dt, bool w, bool a, bool s, bool d, bool up, bool down); + public void Look(float deltaX, float deltaY); +} +``` + +`View` is computed from `Position`, `Yaw`, `Pitch` using `CreateLookAt`. `Projection` is a perspective matrix. `Update` integrates position in the horizontal plane relative to yaw, with space (up) and ctrl (down) adding vertical. `Look` adds to yaw/pitch, clamps pitch to `±89°`. + +### `CameraController` + +```csharp +public sealed class CameraController +{ + public OrbitCamera Orbit { get; } + public FlyCamera Fly { get; } + public ICamera Active { get; private set; } + public bool IsFlyMode => Active == Fly; + + public event Action? ModeChanged; + + public void ToggleFly(); + public void SetAspect(float aspect); +} +``` + +`ToggleFly` flips `Active` and fires `ModeChanged(IsFlyMode)`. `SetAspect` updates both cameras. + +### `GameWindow` input wiring + +- `_camera` field replaced with `_cameraController`. +- `OnLoad` creates the controller, registers a `ModeChanged` handler that toggles cursor mode: + - Entering fly → `mouse.Cursor.CursorMode = CursorMode.Raw` + - Leaving fly → `CursorMode.Normal` +- `Key.F` → `_cameraController.ToggleFly()` +- `Key.Escape` — in orbit mode, closes window (existing); in fly mode, calls `ToggleFly()` to return to orbit (and releases cursor). +- `mouse.MouseMove` — if orbit, existing orbit drag-look. If fly, route deltas (`pos - lastPos`) to `_cameraController.Fly.Look(...)`. +- `mouse.Scroll` — if orbit, existing zoom. If fly, ignored for Phase 2b. +- **New `OnUpdate` handler** — Silk.NET's `IWindow` raises `Update += double dt` once per tick (distinct from `Render`). `GameWindow` registers `_window.Update += OnUpdate`. Inside `OnUpdate`, if `_cameraController.IsFlyMode`, read the current state of W/A/S/D/Space/Ctrl from `_input.Keyboards[0].IsKeyPressed(Key.X)` and call `_cameraController.Fly.Update(dt, ...)`. This separates per-frame simulation (Update) from per-frame drawing (Render) in the standard way. +- `OnRender` passes `_cameraController.Active` to both renderers. + +## Plugin API growth + +### New types in `AcDream.Plugin.Abstractions` + +**`WorldEntitySnapshot`** — `readonly record struct` with `Id`, `SourceId`, `Position: Vector3`, `Rotation: Quaternion`. No `Scale` (Phase 2a decision — AC `Frame` carries no scale). + +**`IGameState`** — interface with one member: `IReadOnlyList Entities`. Phase 2b populates this list once at world load; it never mutates after. + +**`IEvents`** — interface with one event: `event Action EntitySpawned`. Fires once per entity during hydration. **Replay-on-subscribe:** any new handler added via `+=` is immediately invoked for every already-spawned entity before `+=` returns. + +**`IPluginHost`** — gains two properties: `IGameState State { get; }` and `IEvents Events { get; }`. + +### Host-side implementations in `AcDream.App.Plugins` + +**`WorldGameState : IGameState`** — sealed class. Constructor takes `IReadOnlyList entities`. `Entities` property returns that list. + +**`WorldEvents : IEvents`** — sealed class with: +- `FireEntitySpawned(snapshot)` — internal method called by the host during hydration. Adds to `_alreadySpawned`, notifies current subscribers. +- `EntitySpawned` event — the `add` accessor takes a lock, snapshots `_alreadySpawned` into a local array, adds to the subscriber list, releases the lock, then invokes the new handler for each entity in the snapshot array (outside the lock to prevent deadlock). +- Exceptions in handler invocation during replay are swallowed so one broken plugin's subscribe can't poison the rest. + +**`AppPluginHost`** — sealed class implementing `IPluginHost`. Constructor takes `IPluginLogger`, `IGameState`, `IEvents`. Exposes all three as properties. + +### `Program.cs` wiring order + +``` +1. Serilog init +2. Parse dat dir argument +3. Create WorldEvents (empty; will be populated during load) +4. Create a mutable list for entity snapshots; create WorldGameState wrapping it +5. Create AppPluginHost(log, state, events) +6. Scan plugins directory +7. For each plugin result: PluginLoader.Load(..., host) — this calls Initialize +8. For each loaded plugin: Enable() — plugins subscribe here; replay-on-subscribe + handles the fact that no entities exist yet at this point (empty replay is a no-op) +9. Build GameWindow with datDir + worldEvents + the entity-snapshot list +10. window.Run() — OnLoad hydrates entities. For each hydrated entity: + a. Add a WorldEntitySnapshot to the shared list (which WorldGameState exposes) + b. Call worldEvents.FireEntitySpawned(snapshot) + The live fire reaches plugins that subscribed during step 8. +11. When window closes: foreach plugin Disable() +``` + +Note that Phase 2a's Enable-then-Run ordering is preserved. The replay-on-subscribe mechanism makes it work correctly regardless: when a plugin subscribes in Enable, zero entities have been hydrated yet, so the replay is empty, and the 126 live fires during OnLoad reach the subscriber directly. + +### `SmokePlugin` updated + +```csharp +public sealed class SmokePlugin : IAcDreamPlugin +{ + private IPluginHost? _host; + private int _entitiesSeen; + + public void Initialize(IPluginHost host) + { + _host = host; + _host.Log.Info("smoke plugin initialized"); + } + + public void Enable() + { + _host!.Log.Info("smoke plugin enabled"); + _host.Events.EntitySpawned += OnEntitySpawned; + _host.Log.Info($"smoke plugin sees {_entitiesSeen} entities (replay count)"); + } + + public void Disable() + { + if (_host is not null) + _host.Events.EntitySpawned -= OnEntitySpawned; + _host?.Log.Info("smoke plugin disabled"); + } + + private void OnEntitySpawned(WorldEntitySnapshot entity) => _entitiesSeen++; +} +``` + +**Expected console output at end of Phase 2b:** + +``` +[INF] scanning plugins in ...\plugins +[INF] smoke plugin initialized +[INF] loaded plugin acdream.smoke (Smoke Plugin) +[INF] smoke plugin enabled +[INF] smoke plugin sees 0 entities (replay count) ← at subscribe time, world is empty +loaded landblock 0xA9B4FFFF +hydrated 126 entities on landblock 0xA9B4FFFF +(window opens with textured 3x3 Holtburg, orbit camera active) +(user presses F, cursor captures, WASD flies through world) +(user presses F or Escape, cursor releases, back to orbit) +(user closes window) +[INF] smoke plugin disabled +``` + +(If we wanted the replay count to show 126, we'd reorder `Enable()` to fire after world load. Phase 2a's ordering keeps it at 0 — that's fine because the live fires still reach the subscriber, so `_entitiesSeen` will have incremented to 126 by the time the smoke plugin logs `disabled`. We could log it again in `Disable` for visible proof.) + +Actually — the spec should be explicit about this. **`SmokePlugin.Disable()` logs the final count** so we can see the event stream worked: + +```csharp +public void Disable() +{ + if (_host is not null) + _host.Events.EntitySpawned -= OnEntitySpawned; + _host?.Log.Info($"smoke plugin disabled (saw {_entitiesSeen} entities total)"); +} +``` + +Expected Phase 2b end-of-session output at disable time: `smoke plugin disabled (saw 126 entities total)`. + +## Testing strategy + +**TDD (pure CPU, xUnit):** + +- `LandblockMesh.Build` — updated tests for the new `terrainTypeToLayer` parameter. At least one new test asserting the per-vertex `TerrainLayer` value matches the map. +- `FlyCamera` — position integration (WASD moves in the right direction relative to yaw), pitch clamping, view matrix identity when at origin looking down +Y. +- `WorldEvents` — `FireEntitySpawned` before any subscriber has zero observable effect but later subscribers get a replay. Subscribing twice causes two replays. Subscribing, firing, unsubscribing, firing — second fire doesn't reach the removed handler. Exceptions in a handler don't propagate out of `+=` or `FireEntitySpawned`. + +**Manual smoke (real dats, visual):** + +- Textured terrain — does Holtburg show real green/dirt/road textures instead of the height ramp? +- 3×3 neighbors — does the horizon no longer cliff? Are all 9 landblocks visible as you orbit out? +- FlyCamera — does F toggle work? WASD + mouse look feel correct? Escape release? +- SmokePlugin — does the console show `saw 126 entities total` on shutdown? + +**Expected test count at end of Phase 2b:** ~50-55 (42 carried from Phase 2a + 8-13 new). + +## Error handling & failure modes + +**Dat-level:** +- `Region.LandDefs.LandHeightTable` missing → throw at load (Phase 2a behavior unchanged). +- `Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc` missing → log warning, use a single "white" layer 0 as fallback and all terrain renders white. +- A terrain type byte not in the map → use layer 0 (white). +- A `SurfaceTexture` → `RenderSurface` chain break for a terrain type → that layer uploads as magenta; terrain of that type will be visibly wrong (diagnostic, not a crash). + +**GL-level:** +- Shader compile fails on the new integer attribute → throw at load (Phase 1 behavior). +- `GL_TEXTURE_2D_ARRAY` allocation fails for implausible layer counts → throw at load. +- Mouse capture fails on some driver → log error, stay in orbit mode, ignore `F` key. + +**Plugin-level:** +- Replay-on-subscribe handler throws → caught in `WorldEvents.EntitySpawned` add accessor, logged via host logger, replay continues to the next snapshot. Plugin is NOT marked faulted for handler errors. +- Live `FireEntitySpawned` handler throws → caught in `WorldEvents.FireEntitySpawned`, logged, dispatch continues to the next subscriber. + +## Task sequence (rough, full plan comes from writing-plans) + +1. **Expand `Vertex` struct + `LandblockMesh.Build` signature** — add `TerrainLayer` field, take `terrainTypeToLayer` map. Update Phase 1 tests. New test for per-vertex layer mapping. +2. **`TerrainAtlas` class** — collects unique terrain types from loaded landblocks, resolves via `Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc`, decodes via `SurfaceDecoder`, uploads as `GL_TEXTURE_2D_ARRAY`. Manual smoke. +3. **Rewrite `terrain.vert/frag`** — new vertex attribute, `sampler2DArray`, `flat uint vLayer`, per-landblock `uModel`. Update `TerrainRenderer` to bind the atlas and draw per-landblock with model matrix. Manual smoke (single landblock still works). +4. **Wire `WorldView.Load` + neighbor rendering in `GameWindow`** — replace single-landblock load with the 9-landblock view. Per-landblock world origin. Manual smoke (horizon stops cliffing). +5. **`ICamera` interface + `OrbitCamera` refactor** — pure interface, zero behavioral change. Update `TerrainRenderer.Draw` and `StaticMeshRenderer.Draw` signatures. +6. **`FlyCamera`** — new class with Update/Look/View/Projection. TDD the math. +7. **`CameraController` + `GameWindow` input wiring** — F toggle, Escape contextual, cursor mode changes, WASD per-frame update hook. Manual smoke. +8. **`IGameState` + `IEvents` + `WorldEntitySnapshot` in abstractions** — pure types, no tests needed for contracts alone. `WorldGameState` + `WorldEvents` in `AcDream.App.Plugins`. `WorldEvents` replay-on-subscribe is TDD'd. +9. **`AppPluginHost` constructor update + `Program.cs` wiring + `SmokePlugin` subscribes** — end-to-end smoke. Console shows `smoke plugin disabled (saw 126 entities total)`. + +**Stopping point:** Task 9 is the end of Phase 2b. + +## Open questions (deferred, not blocking) + +- Does the `Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc` path in DatReaderWriter match the field shape above? If not, the implementer should match the real type at Task 2 and note it. Algorithm is unchanged either way. +- Should `CameraController` also bind a number key (1/2) as hard switches instead of a F toggle? Deferred; F-toggle is fine for Phase 2b. +- Should `WorldEvents.EntitySpawned` become `EntityAdded`+`EntityRemoved` in Phase 2c when dynamic spawns matter? Yes, probably, but Phase 2b doesn't need `Removed` since there are no despawns. +- Terrain texture blending between adjacent cells — Phase 3+. +- `AcDream.Cli` retirement — still deferred from Phase 1. Not Phase 2b.