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

448 lines
26 KiB
Markdown
Raw Permalink 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.

# 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:
```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<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
```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<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
```csharp
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`:
```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<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`
**`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.
- `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.