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>
26 KiB
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
flatinterpolation 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.Entitiesis 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)
- Full scope: all 8 sketched Phase 2b tasks (11–18 in the original Phase 2 plan) in one session.
GL_TEXTURE_2D_ARRAYfor terrain textures. Per-vertexaTerrainLayerattribute indexes the layer.- 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). - EntitySpawned fires during world load,
WorldEventsimplements replay-on-subscribe so a plugin subscribing inEnable()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:
- Phase 2a's architecture stays intact. The only Phase 2a change is
LandblockMesh.Buildemitting a per-vertex terrain type index alongside position/normal/uv, plusVertexgrowing aTerrainLayerfield.StaticMeshRendererinherits the new vertex stride for free because the mesh shader ignoresTerrainLayerfor static meshes (layer=0 by convention). WorldViewbecomes the entry point for terrain inGameWindow. Returns 9 landblocks + their entities.- Neighbor translation is done via a
uModeluniform per landblock, not baked into vertex positions. Keeps mesh data compact and shareable. - Terrain atlas built once at load from the unique set of terrain type bytes seen in any loaded landblock. Cheap to compute (729 checks).
ICamerais a pure interface. Both cameras implement it.CameraControllerowns both and exposes the active one.WorldGameState.Entitiesis a frozenIReadOnlyList<WorldEntitySnapshot>built once at world load.WorldEventsprovides 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 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:
#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
_camerafield replaced with_cameraController.OnLoadcreates the controller, registers aModeChangedhandler that toggles cursor mode:- Entering fly →
mouse.Cursor.CursorMode = CursorMode.Raw - Leaving fly →
CursorMode.Normal
- Entering fly →
Key.F→_cameraController.ToggleFly()Key.Escape— in orbit mode, closes window (existing); in fly mode, callsToggleFly()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
OnUpdatehandler — Silk.NET'sIWindowraisesUpdate += double dtonce per tick (distinct fromRender).GameWindowregisters_window.Update += OnUpdate. InsideOnUpdate, 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. OnRenderpasses_cameraController.Activeto 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<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.EntitySpawnedevent — theaddaccessor takes a lock, snapshots_alreadySpawnedinto 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 newterrainTypeToLayerparameter. At least one new test asserting the per-vertexTerrainLayervalue 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—FireEntitySpawnedbefore 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+=orFireEntitySpawned.
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 totalon 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.LandHeightTablemissing → throw at load (Phase 2a behavior unchanged).Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDescmissing → 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→RenderSurfacechain 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_ARRAYallocation fails for implausible layer counts → throw at load.- Mouse capture fails on some driver → log error, stay in orbit mode, ignore
Fkey.
Plugin-level:
- Replay-on-subscribe handler throws → caught in
WorldEvents.EntitySpawnedadd accessor, logged via host logger, replay continues to the next snapshot. Plugin is NOT marked faulted for handler errors. - Live
FireEntitySpawnedhandler throws → caught inWorldEvents.FireEntitySpawned, logged, dispatch continues to the next subscriber.
Task sequence (rough, full plan comes from writing-plans)
- Expand
Vertexstruct +LandblockMesh.Buildsignature — addTerrainLayerfield, taketerrainTypeToLayermap. Update Phase 1 tests. New test for per-vertex layer mapping. TerrainAtlasclass — collects unique terrain types from loaded landblocks, resolves viaRegion.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc, decodes viaSurfaceDecoder, uploads asGL_TEXTURE_2D_ARRAY. Manual smoke.- Rewrite
terrain.vert/frag— new vertex attribute,sampler2DArray,flat uint vLayer, per-landblockuModel. UpdateTerrainRendererto bind the atlas and draw per-landblock with model matrix. Manual smoke (single landblock still works). - Wire
WorldView.Load+ neighbor rendering inGameWindow— replace single-landblock load with the 9-landblock view. Per-landblock world origin. Manual smoke (horizon stops cliffing). ICamerainterface +OrbitCamerarefactor — pure interface, zero behavioral change. UpdateTerrainRenderer.DrawandStaticMeshRenderer.Drawsignatures.FlyCamera— new class with Update/Look/View/Projection. TDD the math.CameraController+GameWindowinput wiring — F toggle, Escape contextual, cursor mode changes, WASD per-frame update hook. Manual smoke.IGameState+IEvents+WorldEntitySnapshotin abstractions — pure types, no tests needed for contracts alone.WorldGameState+WorldEventsinAcDream.App.Plugins.WorldEventsreplay-on-subscribe is TDD'd.AppPluginHostconstructor update +Program.cswiring +SmokePluginsubscribes — end-to-end smoke. Console showssmoke 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.TerrainDescpath 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
CameraControlleralso bind a number key (1/2) as hard switches instead of a F toggle? Deferred; F-toggle is fine for Phase 2b. - Should
WorldEvents.EntitySpawnedbecomeEntityAdded+EntityRemovedin Phase 2c when dynamic spawns matter? Yes, probably, but Phase 2b doesn't needRemovedsince there are no despawns. - Terrain texture blending between adjacent cells — Phase 3+.
AcDream.Cliretirement — still deferred from Phase 1. Not Phase 2b.