# acdream Phase 2 design: static meshes, textures, neighbor landblocks **Date:** 2026-04-10 **Status:** Approved — design locked, ready for implementation planning. **Builds on:** Phase 1 (`docs/plans/2026-04-10-phase-1-terrain-and-plugin-scaffold.md`, merged as commit `2089bf3`). ## Goal Turn the single flat-shaded Holtburg terrain patch from Phase 1 into a recognizable textured 3×3 landblock world with all static objects (buildings, trees, rocks) rendered from their GfxObj / Setup sources, explorable by both orbit and free-fly cameras, with a minimal plugin API exposing the world entity list. ## Non-goals - Lighting (flat color + diffuse texture only). Deferred to later phase. - Frustum culling. 9 landblocks is small enough that drawing everything every frame is fine. - Skeletal animation. Static objects only. Monsters/NPCs/players are Phase 3+. - Collision. Free-fly clips through walls. - Level-of-detail (LOD) swaps. Draw the full-res mesh at all distances. - Hot world changes (`IGameState.Entities` is a one-shot snapshot built at load time). - Per-cell terrain texture blending. Hard edges between adjacent terrain types are acceptable. ## Locked decisions (from brainstorming) 1. **Full scope:** geometry + textures + 3×3 neighbor landblocks. 2. **Both cameras:** `OrbitCamera` remains default, `FlyCamera` added, `F` key toggles. `ICamera` interface unifies them. 3. **Minimal plugin API:** `IGameState.Entities` (snapshot) + `IEvents.EntitySpawned` (per-entity-at-load). No overlay yet. 4. **BCnEncoder.Net** for DXT/BCn texture decoding (matches WorldBuilder's choice). ## Architecture overview ``` ┌─────────────────────────────────────────────────────────────────────┐ │ acdream client │ │ │ │ dat directory │ │ │ │ │ ▼ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ AcDream.Core │ │ │ │ │ │ │ │ ┌──────────┐ ┌──────────────┐ ┌────────────┐ │ │ │ │ │ Terrain │ │ World │ │ Textures │ │ │ │ │ │ (P1) │ │ │ │ │ │ │ │ │ │ │ │ LandblockLdr │ │ Surface │ │ │ │ │ │ Landblock│ │ WorldView │ │ Decoder │ │ │ │ │ │ Mesh │ │ (3x3 grid) │ │ (BCn) │ │ │ │ │ └──────────┘ │ WorldEntity │ └────────────┘ │ │ │ │ └──────────────┘ │ │ │ │ ┌──────────────────────────────┐ │ │ │ │ │ Meshing │ │ │ │ │ │ │ │ │ │ │ │ GfxObjMesh.Build │ │ │ │ │ │ SetupMesh.Flatten │ │ │ │ │ └──────────────────────────────┘ │ │ │ └──────┬───────────────┬──────────────┬────────────────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ AcDream.App / Rendering │ │ │ │ │ │ │ │ TerrainRenderer(updated) StaticMeshRenderer(new) │ │ │ │ ↳ atlas sampling ↳ one VAO per GfxObj │ │ │ │ │ │ │ │ TextureCache(new) │ │ │ │ ↳ Surface.id → GL handle │ │ │ │ │ │ │ │ Shader: terrain.vert/frag (updated, samples atlas) │ │ │ │ Shader: mesh.vert/frag (new) │ │ │ │ │ │ │ │ ICamera(new) │ │ │ │ ├── OrbitCamera (P1, refactored behind interface) │ │ │ │ └── FlyCamera (new, WASD + mouse look) │ │ │ │ CameraController (F key toggles) │ │ │ └──────────────┬───────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────┐ │ │ │ Plugin pipeline (P1) │ │ │ │ │ │ │ │ + IGameState.Entities │ (snapshot) │ │ │ + IEvents.EntitySpawned │ (per-entity, at load) │ │ └─────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────┘ ``` **Key principles:** 1. Pure CPU work (`World/`, `Meshing/`, `Textures/`) lives under `AcDream.Core` and is TDD-tested. 2. GPU work (`StaticMeshRenderer`, `TextureCache`, shaders, cameras) lives under `AcDream.App` and is manual-smoke tested against real retail dats. 3. Meshes are **deduplicated by GfxObj id.** One VAO per unique mesh. Entities sharing a GfxObj share its VAO — the draw loop iterates entities and sets per-entity model matrices. 4. Textures are **deduplicated by Surface id.** `TextureCache` lazily uploads on miss. 5. The 3×3 neighbor grid is a concern of `WorldView`, not `LandblockLoader`. The loader loads one landblock; the view composes nine. 6. Plugins receive **immutable snapshots** (`WorldEntitySnapshot`), not live `WorldEntity` references. Protects core state. ## Data flow ``` startup │ ▼ Load center landblock ID (Holtburg 0xA9B4) │ ▼ For each of 9 landblocks in 3x3 grid around center: │ ├─► LandBlock (heightmap) ──► LandblockMesh.Build (Phase 1) │ └─► LandBlockInfo (static objects) │ ▼ For each "stab" (static object placement): │ ├─► Read object id + position + rotation + scale │ ├─► Resolve the id to a GfxObj or a Setup │ │ │ ├── GfxObj ──► extract one CPU mesh │ │ │ └── Setup ──► recursively flatten into N (GfxObjId, PartTransform) │ tuples — one per part │ ├─► Create WorldEntity { Id, SourceId, Position, Rotation, Scale, MeshRefs[] } │ └─► Fire IEvents.EntitySpawned(snapshot) │ ▼ For each unique GfxObj referenced by any entity: │ ├─► Decode its Surfaces through Surface → SurfaceTexture → RenderSurface │ using BCnEncoder.Net for DXT, palette lookup for indexed textures │ ├─► Upload RGBA8 bytes as a GL texture (via TextureCache) │ └─► Build VAO/VBO/EBO for the mesh (via StaticMeshRenderer) │ ▼ Main loop: │ ├─► camera (orbit or fly, via ICamera) │ ├─► TerrainRenderer.Draw(camera, LandblockMeshData[] + origins) │ for each of 9 landblock meshes: │ bind terrain shader + terrain atlas texture │ set model matrix (translate by landblock origin) │ draw │ └─► StaticMeshRenderer.Draw(camera, IEnumerable) group entities by GfxObj id for each group: bind mesh shader bind the GfxObj's diffuse texture for each entity in group: set model matrix (translate + rotate + scale) draw ``` ## Components ### `AcDream.Core/World/` **`WorldEntity`** — sealed class holding per-spawn state: ```csharp public sealed class WorldEntity { public required uint Id { get; init; } public required uint SourceGfxObjOrSetupId { get; init; } public required Vector3 Position { get; init; } public required Quaternion Rotation { get; init; } public required IReadOnlyList MeshRefs { get; init; } } public readonly record struct MeshRef(uint GfxObjId, Matrix4x4 PartTransform); ``` `Id` is a monotonic counter assigned at load time (no wire protocol yet). `SourceGfxObjOrSetupId` is the dat id that this entity was built from; used for deduplication in rendering. **No `Scale` field:** `Stab` and `BuildingInfo` both store a `Frame` with `Origin: Vector3` and `Orientation: Quaternion` — no scale component. Phase 2 renders at unit scale. If Phase 3+ needs scale (e.g., for loot drops or resized objects), it can be added then. **`LandblockLoader`** — static class with `Load(DatCollection dats, uint landblockId) → LoadedLandblock`. Returns the heightmap `LandBlock` + the flat list of `WorldEntity` from `LandBlockInfo`. Loads **both** `LandBlockInfo.Objects` (`Stab` list — small decorations, barrels, rocks, trees) **and** `LandBlockInfo.Buildings` (`BuildingInfo` list — actual buildings). Both types share the same `Frame` shape so they map to `WorldEntity` uniformly. No GPU state. Testable. ```csharp public sealed record LoadedLandblock( uint LandblockId, LandBlock Heightmap, IReadOnlyList Entities); ``` **`WorldView`** — the 3×3 aggregate: ```csharp public sealed class WorldView { public uint CenterLandblockId { get; } public IReadOnlyList Landblocks { get; } public IEnumerable AllEntities => Landblocks.SelectMany(lb => lb.Entities); public static WorldView Load(DatCollection dats, uint centerLandblockId); } ``` Computes neighbor ids from the center: the high 16 bits of a landblock id are an `0xXXYY` coordinate, so neighbors are `(x-1..x+1, y-1..y+1)`. Missing neighbors (edges of the world, or missing in the dat) are silently skipped. ### `AcDream.Core/Meshing/` **`GfxObjMesh.Build(GfxObj) → IReadOnlyList`** — walks `GfxObj.VertexArray` + `GfxObj.Polygons` into one sub-mesh **per referenced `Surface`**. This is the hardest piece of Phase 2. ```csharp public readonly record struct GfxObjSubMesh( uint SurfaceId, // from GfxObj.Surfaces[Polygon.PosSurface] Vertex[] Vertices, uint[] Indices); ``` Key facts (verified in DatReaderWriter and cross-checked against WorldBuilder's `ObjectMeshManager`): - `GfxObj.Surfaces` is a `List>` — **multiple surfaces per GfxObj are the norm, not the exception.** - `Polygon.PosSurface` is a `short` index into `GfxObj.Surfaces`. Different polygons in the same GfxObj can use different surfaces. - `GfxObj.VertexArray.Vertices` is a `Dictionary` — sparse indexing. - `SWVertex.UVs` is a `List` — a single position may have multiple UV sets. - `Polygon.VertexIds` is a `List` — position indices for this polygon's vertices in order. - `Polygon.PosUVIndices` is a `List` — for each vertex in this polygon, which of the `SWVertex.UVs[]` slots to use. So the real rule is: **for each polygon, group by `PosSurface`, emit output vertices `(swVertex.Origin, swVertex.Normal, swVertex.UVs[PosUVIndices[i]])` for each `VertexIds[i]`, deduplicate by `(posIdx, uvIdx)` tuple, fan-triangulate the polygon's output vertices into sub-mesh indices.** The result is one `GfxObjSubMesh` per unique surface. Other challenges: - Polygons can have more than 3 vertices (4, 5, ...). Simple fan triangulation works for convex polygons (AC's polygons are convex). - `Polygon.CullMode` could mean we need to render both sides or one — for Phase 2 we ignore it and render both (no backface culling). - `Polygon.NegSurface` and `NegUVIndices` support double-sided polys with different textures per face — ignored for Phase 2, same reason. We reuse the `Vertex` struct from Phase 1 (`Position`, `Normal`, `TexCoord`) and take `Normal` straight from `SWVertex.Normal` (AC already bakes per-vertex normals). **`SetupMesh.Flatten(Setup, DatCollection) → IReadOnlyList<(uint GfxObjId, Matrix4x4 PartTransform)>`** — walks a `Setup`'s parts and placement frames, yields the flat list of (GfxObjId, transform) tuples for a single `WorldEntity` to reference. ### `AcDream.Core/Textures/` **`SurfaceDecoder.Decode(Surface, DatCollection) → DecodedTexture`** — resolves Surface → SurfaceTexture → RenderSurface, decodes compressed or palette-indexed pixel data. ```csharp public sealed record DecodedTexture(byte[] Rgba8, int Width, int Height); ``` Uses `BCnEncoder.Net` for BC1/BC2/BC3. Handles palette indexed via `Palette` lookup. Falls back to a 1×1 magenta texture on any failure. Testable: feed it a synthetic uncompressed `RenderSurface` and assert byte-exact output. ### `AcDream.App/Rendering/` **`ICamera`** — interface: ```csharp public interface ICamera { Matrix4x4 View { get; } Matrix4x4 Projection { get; } float Aspect { get; set; } } ``` **`OrbitCamera`** — refactored to implement `ICamera` (existing behavior preserved). **`FlyCamera`** — new implementation: ```csharp public sealed class FlyCamera : ICamera { public Vector3 Position { get; set; } = new(96, 96, 100); public float Yaw { get; set; } public float Pitch { get; set; } public float MoveSpeed { get; set; } = 80f; // world units / sec public float MouseSensitivity { get; set; } = 0.002f; public float FovY { get; set; } = MathF.PI / 3f; public float Aspect { get; set; } = 16f / 9f; 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); } ``` WASD moves in the horizontal plane relative to yaw. Space/Ctrl (or up/down) move vertically. Mouse captured with `CursorMode.Raw`. **`CameraController`** — owns both cameras and the active selection: ```csharp public sealed class CameraController { public ICamera Active { get; private set; } public OrbitCamera Orbit { get; } public FlyCamera Fly { get; } public void Toggle(); // flips Active, also flips cursor capture } ``` **`StaticMeshRenderer`** — draws all `WorldEntity` via their `MeshRefs`: ```csharp public sealed unsafe class StaticMeshRenderer : IDisposable { public StaticMeshRenderer(GL gl, Shader meshShader, TextureCache textures); // Upload all sub-meshes for a given GfxObj id (idempotent; cached). public void EnsureUploaded(uint gfxObjId, IReadOnlyList subMeshes); // Draw all entities grouped by GfxObj, then within each GfxObj by sub-mesh // (each sub-mesh has its own surface/texture). public void Draw(ICamera camera, IEnumerable entities); public void Dispose(); } ``` Internally holds a `Dictionary` where `GfxObjGpu` bundles a list of `SubMeshGpu` records — one VAO/VBO/EBO per sub-mesh, each with its own `SurfaceId`. The draw loop iterates entities, and for each entity iterates that entity's GfxObj's sub-meshes, switching texture between sub-meshes. **`TextureCache`** — App-side GL texture ownership: ```csharp public sealed class TextureCache : IDisposable { public TextureCache(GL gl, DatCollection dats); // Get or upload the texture for a Surface id. Returns GL texture handle. public uint GetOrUpload(uint surfaceId); public void Dispose(); // delete all handles } ``` On cache miss, calls `SurfaceDecoder.Decode`, uploads the RGBA8 bytes as a `GL_RGBA8` texture, stores the handle. **Terrain atlas** — separate concern from per-GfxObj textures. At load time we gather the unique terrain-type texture ids from the loaded landblocks, decode each, and upload as a `GL_TEXTURE_2D_ARRAY` with each terrain type as a layer. The updated terrain shader samples the array with the per-vertex `aTerrainIndex` (a byte value taken from `TerrainInfo[i]`). **Shader updates:** `terrain.vert` — gains `in uint aTerrainIndex; out flat uint vTerrainIndex; uniform mat4 uModel;`. Position transformed by `uModel` before view/projection. `terrain.frag` — gains `uniform sampler2DArray uTerrainAtlas;`. Samples atlas at `(vTexCoord, vTerrainIndex)`. Falls back to the Phase 1 height ramp if texture is missing. `mesh.vert` / `mesh.frag` — new pair for static meshes. Position × model × view × projection. Single `sampler2D uDiffuse`. No lighting. ### `AcDream.Plugin.Abstractions/` **`IGameState`** — new interface: ```csharp public interface IGameState { IReadOnlyList Entities { get; } } public readonly record struct WorldEntitySnapshot( uint Id, uint SourceId, Vector3 Position, Quaternion Rotation); ``` (No `Scale` field — Phase 2 renders at unit scale because `Stab.Frame` and `BuildingInfo.Frame` don't carry a scale component.) **`IEvents`** — new interface: ```csharp public interface IEvents { event Action EntitySpawned; } ``` **`IPluginHost`** — gains two properties: ```csharp public interface IPluginHost { IPluginLogger Log { get; } IGameState State { get; } // NEW IEvents Events { get; } // NEW } ``` Plugins get snapshots, not live `WorldEntity` references — this protects core state from plugin mutation and prevents coupling to rendering details like `MeshRef`. ### `AcDream.App/Plugins/` **`WorldGameState`** — sealed class implementing `IGameState`. Built from a `WorldView`. `Entities` returns a cached snapshot list rebuilt lazily when the world changes (in Phase 2, only at initial load). **`WorldEvents`** — sealed class implementing `IEvents`. Holds the `EntitySpawned` event. Fired by `WorldGameState` during snapshot building. **`AppPluginHost`** extended to own `State` and `Events`, handed to all plugins via `Initialize`. ## Testing strategy **TDD (pure CPU, xUnit):** - `GfxObjMesh.Build` — synthetic GfxObj, vertex/index count assertions, UV forwarding, triangulation of N-gons - `SetupMesh.Flatten` — synthetic Setup with 2-3 parts, assert flattened (GfxObjId, Matrix4x4) tuples - `SurfaceDecoder.Decode` — synthetic uncompressed RenderSurface, RGBA8 byte-exact assertion; BC1 decode exercised with a known small blob - `LandblockLoader.Load` — synthetic or real Holtburg, entity count assertion - `WorldView` — neighbor id math, edge handling **Manual smoke (against retail dats):** - `StaticMeshRenderer` — do buildings appear? - `TextureCache` — do textures look right? - `FlyCamera` — does WASD move, does mouse look work? - `CameraController` — does F toggle? - Plugin round-trip — `SmokePlugin` subscribes to `EntitySpawned` and logs entity count at `Enable` time **Expected test count:** ~30-35 (17 from Phase 1 + 13-18 new). ## Error handling & failure modes **Dat-level (load):** - Missing neighbor → silent skip - `LandBlockInfo` missing → log warning, render terrain without objects - GfxObj/Setup unresolvable → log warning, skip entity, continue - Surface/RenderSurface missing/corrupt → 1×1 magenta fallback texture **GL-level (runtime):** - Texture upload fails → log error, magenta fallback - Shader compile fails → throw at load time (same as Phase 1) - Degenerate mesh (zero triangles) → log warning, skip rendering **Plugin-level:** - `EntitySpawned` handler throws → catch, log, continue (don't mark plugin faulted — too disruptive) - `IGameState.Entities` accessed during load → returns current snapshot, may be mid-build **Primary risk:** `GfxObjMesh.Build` is the piece most likely to produce visually wrong output. The test suite catches gross errors (counts, triangulation); subtle bugs (flipped triangles, wrong UV mapping) only show visually. Mitigation: smoke-test each sub-step separately — wireframe first, then UVs, then textures. ## Task sequence (rough, full bite-sized plan in a separate doc) 1. Add `Meshing/`, `World/`, `Textures/` folder scaffolding under `AcDream.Core` 2. `GfxObjMesh.Build` (TDD, pure CPU — the hardest piece) 3. `SurfaceDecoder.Decode` (TDD, BCnEncoder.Net) 4. `LandblockLoader.Load` (TDD) 5. `SetupMesh.Flatten` (TDD) 6. `WorldView` 3×3 grid + neighbor id math (TDD) 7. `TextureCache` (App-side GL wrapper) 8. `StaticMeshRenderer` + new mesh shader (manual smoke) 9. Integrate `StaticMeshRenderer` into `GameWindow` — buildings flat white 10. Hook textures into `StaticMeshRenderer` — buildings now textured 11. Terrain atlas texture + updated terrain shader — ground textured 12. Render neighbor landblocks — horizon no longer cliffs 13. Extract `ICamera`, refactor `OrbitCamera` behind it 14. `FlyCamera` implementation 15. `CameraController` + F toggle 16. Add `IGameState` + `IEvents` to abstractions 17. `WorldGameState` + `WorldEvents` + `AppPluginHost` wire-up 18. Extend `SmokePlugin` to subscribe to `EntitySpawned` — end-to-end smoke **Ordering rationale:** - Pure CPU (1-6) first so TDD works without GL - Static meshes (7-10) before terrain textures (11) because they're the bigger visual reward - Neighbors (12) after single-landblock works — isolates the neighbor logic - Camera (13-15) late because independent and doesn't block visual work - Plugin API (16-18) last — discipline requires it, touches less than the rest ## Open questions (deferred, not blocking) - ~~Does `GfxObj` always reference one `Surface`?~~ **Answered during design spike:** multiple surfaces per GfxObj are the norm. Mesh builder produces one sub-mesh per referenced surface. WorldBuilder's `ObjectMeshManager` confirms this is the canonical approach. - Terrain atlas size — 16 layers enough, or more? Deferred to Task 11 once we know the retail terrain type count. - Should `WorldEntitySnapshot` be sent as a single list or as an `IEnumerable` (lazy)? Plugins want indexed access. Locked: list. - Collision, animation, hot reload of plugins — all Phase 3+.