acdream/docs/plans/2026-04-10-phase-2b-design.md
Erik f61f356145 docs: phase 2b design — atlas textures, neighbors, dual cameras, plugin api
Locks the four decisions from brainstorming: full scope (all 8 sketched
tasks in one Phase 2b), GL_TEXTURE_2D_ARRAY for terrain atlas with a
per-vertex flat uint layer attribute, raw cursor capture FlyCamera with
F toggle and Escape release, and WorldEvents.EntitySpawned with
replay-on-subscribe so plugin ordering doesn't matter.

Grows AcDream.Plugin.Abstractions by IGameState + IEvents +
WorldEntitySnapshot. Host-side WorldGameState + WorldEvents implementations
live in AcDream.App.Plugins. AppPluginHost constructor gains two
parameters. Program.cs wiring order keeps Phase 2a's Enable-before-Run,
relying on replay-on-subscribe to make subscription-before-world-load
produce the right observable behavior.

Task 1 expands Vertex struct and LandblockMesh signature — this affects
StaticMeshRenderer's vertex stride too, so Phase 2a's shader bindings
need a matching update.

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

26 KiB
Raw Permalink Blame History

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 (1118 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<byte terrainType, uint layer>                        │ │
│  │                                                                      │ │
│  │  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<WorldEntitySnapshot> 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:

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<SurfaceTexture>. At load time we walk this list to build a Dictionary<byte, uint> 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

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

public static LandblockMeshData Build(
    LandBlock block,
    float[] heightTable,
    IReadOnlyDictionary<byte, uint> 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

public sealed class TerrainAtlas : IDisposable
{
    public uint GlTexture { get; }
    public IReadOnlyDictionary<byte, uint> TerrainTypeToLayer { get; }
    public int LayerCount { get; }

    public static TerrainAtlas Build(
        GL gl,
        DatCollection dats,
        IEnumerable<LandBlock> loadedLandblocks);

    public void Dispose();
}

Build walks all vertices of all loaded landblocks to collect the unique terrain types, resolves each to a SurfaceTextureRenderSurface → 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:

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

#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

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

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

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

WorldEntitySnapshotreadonly 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<WorldEntitySnapshot> Entities. Phase 2b populates this list once at world load; it never mutates after.

IEvents — interface with one event: event Action<WorldEntitySnapshot> 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<WorldEntitySnapshot> 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

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:

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.
  • WorldEventsFireEntitySpawned 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 SurfaceTextureRenderSurface 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.