Verified the real GfxObj data shape against DatReaderWriter's generated types and WorldBuilder's ObjectMeshManager. Three corrections: - GfxObj.Surfaces is a list and Polygon.PosSurface indexes into it; multiple surfaces per GfxObj are the norm. GfxObjMesh.Build now returns IReadOnlyList<GfxObjSubMesh> — one sub-mesh per referenced surface. StaticMeshRenderer draws entities by grouping entities by GfxObj and then by sub-mesh within each GfxObj. Matches the approach WorldBuilder takes. - LandBlockInfo has both Objects (Stabs — small decorations, rocks, trees) AND Buildings (BuildingInfo). LandblockLoader loads both lists and maps them uniformly to WorldEntity since both types share the Frame shape. - Stab.Frame and BuildingInfo.Frame carry position+orientation only, no scale component. WorldEntity.Scale dropped. WorldEntitySnapshot loses its Scale field too. Phase 3+ can add it back if needed. Closes the one open design question flagged in the original doc. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
23 KiB
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.Entitiesis 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)
- Full scope: geometry + textures + 3×3 neighbor landblocks.
- Both cameras:
OrbitCameraremains default,FlyCameraadded,Fkey toggles.ICamerainterface unifies them. - Minimal plugin API:
IGameState.Entities(snapshot) +IEvents.EntitySpawned(per-entity-at-load). No overlay yet. - 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:
- Pure CPU work (
World/,Meshing/,Textures/) lives underAcDream.Coreand is TDD-tested. - GPU work (
StaticMeshRenderer,TextureCache, shaders, cameras) lives underAcDream.Appand is manual-smoke tested against real retail dats. - 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.
- Textures are deduplicated by Surface id.
TextureCachelazily uploads on miss. - The 3×3 neighbor grid is a concern of
WorldView, notLandblockLoader. The loader loads one landblock; the view composes nine. - Plugins receive immutable snapshots (
WorldEntitySnapshot), not liveWorldEntityreferences. 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<WorldEntity>)
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:
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<MeshRef> 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.
public sealed record LoadedLandblock(
uint LandblockId,
LandBlock Heightmap,
IReadOnlyList<WorldEntity> Entities);
WorldView — the 3×3 aggregate:
public sealed class WorldView
{
public uint CenterLandblockId { get; }
public IReadOnlyList<LoadedLandblock> Landblocks { get; }
public IEnumerable<WorldEntity> 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<GfxObjSubMesh> — walks GfxObj.VertexArray + GfxObj.Polygons into one sub-mesh per referenced Surface. This is the hardest piece of Phase 2.
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.Surfacesis aList<QualifiedDataId<Surface>>— multiple surfaces per GfxObj are the norm, not the exception.Polygon.PosSurfaceis ashortindex intoGfxObj.Surfaces. Different polygons in the same GfxObj can use different surfaces.GfxObj.VertexArray.Verticesis aDictionary<ushort, SWVertex>— sparse indexing.SWVertex.UVsis aList<Vec2Duv>— a single position may have multiple UV sets.Polygon.VertexIdsis aList<short>— position indices for this polygon's vertices in order.Polygon.PosUVIndicesis aList<byte>— for each vertex in this polygon, which of theSWVertex.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.CullModecould mean we need to render both sides or one — for Phase 2 we ignore it and render both (no backface culling).Polygon.NegSurfaceandNegUVIndicessupport 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.
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:
public interface ICamera
{
Matrix4x4 View { get; }
Matrix4x4 Projection { get; }
float Aspect { get; set; }
}
OrbitCamera — refactored to implement ICamera (existing behavior preserved).
FlyCamera — new implementation:
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:
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:
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<GfxObjSubMesh> 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<WorldEntity> entities);
public void Dispose();
}
Internally holds a Dictionary<uint, GfxObjGpu> 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:
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:
public interface IGameState
{
IReadOnlyList<WorldEntitySnapshot> 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:
public interface IEvents
{
event Action<WorldEntitySnapshot> EntitySpawned;
}
IPluginHost — gains two properties:
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-gonsSetupMesh.Flatten— synthetic Setup with 2-3 parts, assert flattened (GfxObjId, Matrix4x4) tuplesSurfaceDecoder.Decode— synthetic uncompressed RenderSurface, RGBA8 byte-exact assertion; BC1 decode exercised with a known small blobLandblockLoader.Load— synthetic or real Holtburg, entity count assertionWorldView— 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 —
SmokePluginsubscribes toEntitySpawnedand logs entity count atEnabletime
Expected test count: ~30-35 (17 from Phase 1 + 13-18 new).
Error handling & failure modes
Dat-level (load):
- Missing neighbor → silent skip
LandBlockInfomissing → 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:
EntitySpawnedhandler throws → catch, log, continue (don't mark plugin faulted — too disruptive)IGameState.Entitiesaccessed 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)
- Add
Meshing/,World/,Textures/folder scaffolding underAcDream.Core GfxObjMesh.Build(TDD, pure CPU — the hardest piece)SurfaceDecoder.Decode(TDD, BCnEncoder.Net)LandblockLoader.Load(TDD)SetupMesh.Flatten(TDD)WorldView3×3 grid + neighbor id math (TDD)TextureCache(App-side GL wrapper)StaticMeshRenderer+ new mesh shader (manual smoke)- Integrate
StaticMeshRendererintoGameWindow— buildings flat white - Hook textures into
StaticMeshRenderer— buildings now textured - Terrain atlas texture + updated terrain shader — ground textured
- Render neighbor landblocks — horizon no longer cliffs
- Extract
ICamera, refactorOrbitCamerabehind it FlyCameraimplementationCameraController+ F toggle- Add
IGameState+IEventsto abstractions WorldGameState+WorldEvents+AppPluginHostwire-up- Extend
SmokePluginto subscribe toEntitySpawned— 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)
DoesAnswered during design spike: multiple surfaces per GfxObj are the norm. Mesh builder produces one sub-mesh per referenced surface. WorldBuilder'sGfxObjalways reference oneSurface?ObjectMeshManagerconfirms 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
WorldEntitySnapshotbe sent as a single list or as anIEnumerable(lazy)? Plugins want indexed access. Locked: list. - Collision, animation, hot reload of plugins — all Phase 3+.