diff --git a/docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md b/docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md new file mode 100644 index 0000000..d1a9642 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md @@ -0,0 +1,1796 @@ +# Phase N.5b — Terrain on the Modern Rendering Path — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Lift outdoor terrain rendering onto N.5's modern primitives (bindless textures + `glMultiDrawElementsIndirect`), preserving visible identity to today and preserving physics-vs-visual Z agreement (issue #51). + +**Architecture:** Single global VBO/EBO with a slot allocator (one slot per landblock). Per-frame: build a `DrawElementsIndirectCommand` array from visible slots, upload, dispatch via `glMultiDrawElementsIndirect`. Atlas textures use bindless handles (one `sampler2DArray` uniform per atlas, set per-frame via `glProgramUniformHandleARB`). Mesh source is unchanged — `LandblockMesh.Build` (using retail's `FSplitNESW` formula via `TerrainBlending.CalculateSplitDirection`). + +**Tech Stack:** .NET 10, C#, Silk.NET.OpenGL 2.23, `Silk.NET.OpenGL.Extensions.ARB` (bindless), GLSL 4.60 + `GL_ARB_bindless_texture`. xUnit for tests. + +**Spec:** [`docs/superpowers/specs/2026-05-09-phase-n5b-terrain-modern-design.md`](../specs/2026-05-09-phase-n5b-terrain-modern-design.md) (commit `b35ddf3`). +**Substrate:** N.5 SHIP at `27eaf4e` + ship-amendment `e0dbc9c`. + +--- + +## File map + +**Create:** +- `src/AcDream.App/Rendering/TerrainModernRenderer.cs` — the dispatcher (~400 lines). +- `src/AcDream.Core/Terrain/TerrainSlotAllocator.cs` — pure-CPU slot management + DEIC builder (~150 lines). **In Core, not App, so the App-side renderer can compose it; tests in Core.Tests.** +- `src/AcDream.App/Rendering/Shaders/terrain_modern.vert` — port of today's `terrain.vert` with bindless preamble (~150 lines). +- `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` — port of today's `terrain.frag` with bindless preamble (~150 lines). +- `tests/AcDream.Core.Tests/Terrain/TerrainSlotAllocatorTests.cs` — pure-CPU unit tests for slot allocator + DEIC builder (~200 lines). +- `tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs` — Z-conformance sentinel for issue #51 (~150 lines). +- `docs/plans/2026-05-09-phase-n5b-perf-baseline.md` — before/after CPU dispatcher numbers. +- `memory/project_phase_n5b_state.md` — high-value gotchas surfaced during implementation. + +**Modify:** +- `src/AcDream.App/Rendering/TerrainAtlas.cs` — add `BindlessSupport? bindless` ctor parameter + `GetBindlessHandles()` method + two-phase Dispose. +- `src/AcDream.App/Rendering/GameWindow.cs` — field type swap + ctor swap + `[TERRAIN-DIAG]` rollup callback. +- `CLAUDE.md` — add N.5b to "WB integration cribs". +- `docs/plans/2026-04-11-roadmap.md` — N.5b → "Shipped" row. +- `docs/ISSUES.md` — issue #51 → "Recently closed" with SHIP commit SHA. + +**Delete (Task 9 — only after Task 8 ships clean visually):** +- `src/AcDream.App/Rendering/TerrainChunkRenderer.cs` +- `src/AcDream.App/Rendering/TerrainRenderer.cs` +- `src/AcDream.App/Rendering/Shaders/terrain.vert` +- `src/AcDream.App/Rendering/Shaders/terrain.frag` + +--- + +## Dependency graph (what can run in parallel) + +``` +Phase A (parallel — 5 subagents): + T1 (TerrainAtlas bindless extension) + T2 (TerrainSlotAllocator + tests, T2 = code+tests in one task) + T4 (terrain_modern.vert) + T5 (terrain_modern.frag) + T7 (TerrainModernConformanceTests — independent of T6 because the test + verifies LandblockMesh.Build output, which T6 just consumes) + +Phase B (after Phase A — sequential): + T6 (TerrainModernRenderer — depends on T1, T2, T4, T5) + +Phase C (after T6 — sequential): + T8 (GameWindow integration — depends on T6) + +USER VERIFICATION GATE (visual checks at four scenes; ship-blocking) + +Phase D (parallel after gate): + T9 (Delete legacy) + T10 (Roadmap + ISSUES + memory + perf baseline doc) +``` + +The user authorized up to 10 parallel subagents. Phase A uses 5; Phase D uses 2. Phase B and C are single-task serial points. + +--- + +## Workflow per task + +1. Read the spec section the task implements. +2. For TDD-friendly tasks (T2 slot allocator, T7 conformance): write the failing test → run → verify failure → implement → run → verify pass → commit. +3. For GL-integration tasks (T1, T6, T8) and shader tasks (T4, T5): implement → build green → smoke check → commit. (Cannot TDD bindless calls without a headless GL context; integration verification happens at T8.) +4. After every commit, run: + - `dotnet build` (full solution; must be 0 errors) + - `dotnet test --filter "FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~TerrainBlending|FullyQualifiedName~LandblockMesh"` (must be all green) + +Commit message convention (matching N.5): +- Tasks 1-7: `phase(N.5b) Task N: ` +- Tasks 8-10: `phase(N.5b): ` +- Final SHIP: `phase(N.5b): SHIP — ` + +Always co-author: `Co-Authored-By: Claude Opus 4.7 (1M context) ` + +--- + +## Task 1: TerrainAtlas bindless extension + +**Goal:** Add a `GetBindlessHandles()` method that returns 64-bit bindless handles for the terrain + alpha texture arrays. Mirror the pattern from `TextureCache.cs:32-47` (constructor takes optional `BindlessSupport`). + +**Files:** +- Modify: `src/AcDream.App/Rendering/TerrainAtlas.cs` + +**No standalone tests** — `BindlessSupport.GetResidentHandle` requires a live GL context. Integration verification happens at Task 8 (the renderer uses these handles). + +- [ ] **Step 1.1: Add BindlessSupport ctor parameter + handle cache fields** + +In `src/AcDream.App/Rendering/TerrainAtlas.cs`, modify the private constructor at line 56 to accept an optional `BindlessSupport? bindless` parameter: + +```csharp +private readonly Wb.BindlessSupport? _bindless; + +// Cached bindless handles. Generated lazily on first GetBindlessHandles() call; +// reused for the lifetime of the atlas. +private ulong _terrainHandle; +private ulong _alphaHandle; +private bool _handlesGenerated; + +private TerrainAtlas( + GL gl, + Wb.BindlessSupport? bindless, + uint glTexture, IReadOnlyDictionary map, int layerCount, + uint glAlphaTexture, int alphaLayerCount, + IReadOnlyList cornerLayers, IReadOnlyList sideLayers, IReadOnlyList roadLayers, + IReadOnlyList cornerTCodes, IReadOnlyList sideTCodes, IReadOnlyList roadRCodes) +{ + _gl = gl; + _bindless = bindless; + GlTexture = glTexture; + // ... (rest unchanged) +} +``` + +- [ ] **Step 1.2: Update `Build` and `BuildFallback` to accept + propagate the optional BindlessSupport** + +In `TerrainAtlas.Build`, change the signature: + +```csharp +public static TerrainAtlas Build(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = null) +``` + +At the end of `Build`, pass `bindless` to the `new TerrainAtlas(...)` call (insert as second parameter after `gl`). + +In `BuildFallback`, change signature to `BuildFallback(GL gl, Wb.BindlessSupport? bindless = null)` and pass through. + +Find the call to `BuildFallback(gl)` inside `Build` and change to `BuildFallback(gl, bindless)`. + +- [ ] **Step 1.3: Add `GetBindlessHandles()` method** + +After the property declarations (around line 55), add: + +```csharp +/// +/// Get 64-bit bindless handles for the terrain + alpha texture arrays. +/// Throws if the atlas was constructed +/// without a instance. Handles are generated +/// lazily on first call and cached for the atlas's lifetime; both textures +/// are made resident. +/// +public (ulong terrain, ulong alpha) GetBindlessHandles() +{ + if (_bindless is null) + throw new InvalidOperationException( + "TerrainAtlas was constructed without BindlessSupport; cannot return bindless handles."); + if (!_handlesGenerated) + { + _terrainHandle = _bindless.GetResidentHandle(GlTexture); + _alphaHandle = _bindless.GetResidentHandle(GlAlphaTexture); + _handlesGenerated = true; + } + return (_terrainHandle, _alphaHandle); +} +``` + +- [ ] **Step 1.4: Update Dispose for two-phase bindless cleanup** + +Replace the existing `Dispose` method (line 381) with the two-phase pattern (mirror `TextureCache.Dispose` which is in N.5's spec section §2 Decision: "ALL MakeNonResident first, then ALL DeleteTexture"): + +```csharp +public void Dispose() +{ + // Phase 1: release bindless residency BEFORE deleting textures. + // ARB_bindless_texture requires this ordering; interleaving is UB. + if (_handlesGenerated && _bindless is not null) + { + _bindless.MakeNonResident(_terrainHandle); + _bindless.MakeNonResident(_alphaHandle); + _handlesGenerated = false; + } + + // Phase 2: delete the underlying GL textures. + _gl.DeleteTexture(GlTexture); + _gl.DeleteTexture(GlAlphaTexture); +} +``` + +- [ ] **Step 1.5: Build green** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo` +Expected: `Build succeeded. 0 Warning(s) 0 Error(s)`. (TerrainAtlas's existing callers all pass `Build(gl, dats)` without the new optional parameter; the default `bindless = null` keeps them working.) + +- [ ] **Step 1.6: Commit** + +```bash +git add src/AcDream.App/Rendering/TerrainAtlas.cs +git commit -m "$(cat <<'EOF' +phase(N.5b) Task 1: TerrainAtlas bindless extension + +Add optional BindlessSupport ctor parameter + GetBindlessHandles() +method that returns (terrainHandle, alphaHandle) ulongs with both +textures made resident. Two-phase Dispose mirroring TextureCache +(MakeNonResident before DeleteTexture per ARB_bindless_texture spec). + +Existing callers pass `Build(gl, dats)` unchanged; bindless = null +default keeps them working until T6/T8 wires the renderer. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: TerrainSlotAllocator (CPU) + tests + +**Goal:** Pure-CPU class managing the slot allocator (free-list + capacity tracking) and the DEIC array builder. Unit-testable in isolation. + +**Files:** +- Create: `src/AcDream.Core/Terrain/TerrainSlotAllocator.cs` +- Create: `tests/AcDream.Core.Tests/Terrain/TerrainSlotAllocatorTests.cs` + +- [ ] **Step 2.1: Write the failing tests first (TDD)** + +Create `tests/AcDream.Core.Tests/Terrain/TerrainSlotAllocatorTests.cs`: + +```csharp +using AcDream.Core.Terrain; +using Xunit; + +namespace AcDream.Core.Tests.Terrain; + +public class TerrainSlotAllocatorTests +{ + [Fact] + public void Allocate_FromFreshAllocator_ReturnsZero() + { + var alloc = new TerrainSlotAllocator(initialCapacity: 8); + Assert.Equal(0, alloc.Allocate(out _)); + } + + [Fact] + public void Allocate_TwoTimes_ReturnsZeroThenOne() + { + var alloc = new TerrainSlotAllocator(initialCapacity: 8); + Assert.Equal(0, alloc.Allocate(out _)); + Assert.Equal(1, alloc.Allocate(out _)); + } + + [Fact] + public void FreeThenAllocate_ReusesFreedSlot() + { + var alloc = new TerrainSlotAllocator(initialCapacity: 8); + var s0 = alloc.Allocate(out _); + var s1 = alloc.Allocate(out _); + alloc.Free(s0); + Assert.Equal(s0, alloc.Allocate(out _)); + } + + [Fact] + public void FreeOrderedFreshAllocs_ReturnsInFifoOrder() + { + var alloc = new TerrainSlotAllocator(initialCapacity: 8); + var s0 = alloc.Allocate(out _); + var s1 = alloc.Allocate(out _); + var s2 = alloc.Allocate(out _); + alloc.Free(s0); + alloc.Free(s2); + // FIFO: s0 first because freed first. + Assert.Equal(s0, alloc.Allocate(out _)); + Assert.Equal(s2, alloc.Allocate(out _)); + } + + [Fact] + public void Allocate_BeyondInitialCapacity_SignalsNeedsGrow() + { + var alloc = new TerrainSlotAllocator(initialCapacity: 2); + alloc.Allocate(out var grow0); + alloc.Allocate(out var grow1); + alloc.Allocate(out var grow2); // exceeds initial capacity + Assert.False(grow0); + Assert.False(grow1); + Assert.True(grow2); + } + + [Fact] + public void GrowTo_DoublesCapacityCorrectly() + { + var alloc = new TerrainSlotAllocator(initialCapacity: 4); + alloc.GrowTo(8); + Assert.Equal(8, alloc.Capacity); + alloc.GrowTo(64); + Assert.Equal(64, alloc.Capacity); + } + + [Fact] + public void LoadedCount_TracksAllocAndFree() + { + var alloc = new TerrainSlotAllocator(initialCapacity: 8); + Assert.Equal(0, alloc.LoadedCount); + var s0 = alloc.Allocate(out _); + var s1 = alloc.Allocate(out _); + Assert.Equal(2, alloc.LoadedCount); + alloc.Free(s0); + Assert.Equal(1, alloc.LoadedCount); + } + + [Fact] + public void Free_TwiceForSameSlot_Throws() + { + var alloc = new TerrainSlotAllocator(initialCapacity: 8); + var s0 = alloc.Allocate(out _); + alloc.Free(s0); + Assert.Throws(() => alloc.Free(s0)); + } +} +``` + +- [ ] **Step 2.2: Run tests to verify they fail** + +Run: `dotnet test --filter "FullyQualifiedName~TerrainSlotAllocatorTests" --nologo` +Expected: build error — `TerrainSlotAllocator` type not found. + +- [ ] **Step 2.3: Implement TerrainSlotAllocator** + +Create `src/AcDream.Core/Terrain/TerrainSlotAllocator.cs`: + +```csharp +using System; +using System.Collections.Generic; + +namespace AcDream.Core.Terrain; + +/// +/// Pure-CPU slot allocator for the terrain modern dispatcher's global VBO/EBO. +/// One slot = one landblock's worth of mesh data (384 verts + 384 indices). +/// Uses a FIFO free-list for slot recycling and a monotonic counter for +/// first-time growth, mirroring WorldBuilder's TerrainRenderManager pattern. +/// All bookkeeping is CPU-side; the GPU buffer growth itself is performed +/// by TerrainModernRenderer when sets needsGrow=true. +/// +public sealed class TerrainSlotAllocator +{ + private readonly Queue _freeSlots = new(); + private readonly HashSet _liveSlots = new(); + private int _nextFreeSlot; + private int _capacity; + + public TerrainSlotAllocator(int initialCapacity = 64) + { + if (initialCapacity <= 0) + throw new ArgumentOutOfRangeException(nameof(initialCapacity), "must be > 0"); + _capacity = initialCapacity; + } + + /// Current capacity in slots. Growable via . + public int Capacity => _capacity; + + /// Slots currently in use (allocated minus freed). + public int LoadedCount => _liveSlots.Count; + + /// + /// Allocate a slot index. Reuses a freed slot via FIFO if available, + /// otherwise hands out the next monotonic index. Sets + /// to true when the returned slot index is + /// at or beyond current capacity — caller must + /// before using the slot. + /// + public int Allocate(out bool needsGrow) + { + int slot; + if (_freeSlots.TryDequeue(out var freed)) + { + slot = freed; + } + else + { + slot = _nextFreeSlot++; + } + _liveSlots.Add(slot); + needsGrow = slot >= _capacity; + return slot; + } + + /// + /// Return a slot to the free list. Throws if the slot wasn't currently + /// allocated (catches double-free bugs). + /// + public void Free(int slot) + { + if (!_liveSlots.Remove(slot)) + throw new InvalidOperationException( + $"Slot {slot} was not allocated (double-free or unknown slot)."); + _freeSlots.Enqueue(slot); + } + + /// Update capacity counter after the caller has grown the GPU buffers. + public void GrowTo(int newCapacity) + { + if (newCapacity < _capacity) + throw new ArgumentException("Capacity can only grow", nameof(newCapacity)); + _capacity = newCapacity; + } +} +``` + +- [ ] **Step 2.4: Run tests to verify all pass** + +Run: `dotnet test --filter "FullyQualifiedName~TerrainSlotAllocatorTests" --nologo` +Expected: `Passed: 8, Failed: 0` in <1 second. + +- [ ] **Step 2.5: Commit** + +```bash +git add src/AcDream.Core/Terrain/TerrainSlotAllocator.cs tests/AcDream.Core.Tests/Terrain/TerrainSlotAllocatorTests.cs +git commit -m "$(cat <<'EOF' +phase(N.5b) Task 2: TerrainSlotAllocator + tests + +Pure-CPU slot allocator for the terrain modern dispatcher's global +VBO/EBO. FIFO free-list + monotonic counter, mirroring WB's +TerrainRenderManager pattern. Caller (TerrainModernRenderer) handles +GPU buffer growth when Allocate sets needsGrow=true. + +8 unit tests cover: fresh-allocator returns slot 0, sequential +allocs, free+alloc reuse, FIFO ordering, needsGrow signaling on +capacity overflow, GrowTo, LoadedCount tracking, and double-free +detection. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: (merged into Task 2 above) — n/a + +(The spec listed this as a separate task; in practice TDD writes test+code together. Skipped.) + +--- + +## Task 4: terrain_modern.vert + +**Goal:** Vertex shader for the modern terrain dispatcher. Bit-identical math to today's `terrain.vert` with one structural change: bindless `sampler2DArray` uniform (texture access syntactically unchanged in GLSL; bindless-ness is invisible at the shader level — the C# side sets the handle via `glProgramUniformHandleARB`). + +**Files:** +- Create: `src/AcDream.App/Rendering/Shaders/terrain_modern.vert` + +**No unit tests** — shader correctness is verified at integration (Task 8). + +- [ ] **Step 4.1: Read today's `terrain.vert`** + +Read `src/AcDream.App/Rendering/Shaders/terrain.vert` end-to-end (147 lines). The new shader is a 1:1 port with two preamble changes: + +1. `#version 460 core` (was 430) +2. `#extension GL_ARB_bindless_texture : require` added immediately after the version line + +Everything else stays bit-for-bit identical (vertex attribute layout, SceneLighting UBO, AdjustPlanes lighting bake, gl_VertexID corner mapping, etc.). + +- [ ] **Step 4.2: Write the new shader** + +Create `src/AcDream.App/Rendering/Shaders/terrain_modern.vert`: + +```glsl +#version 460 core +#extension GL_ARB_bindless_texture : require + +// Phase N.5b: terrain shader on the modern bindless dispatcher. +// Math identical to terrain.vert (Phase 3c per-cell mesh + Phase G AdjustPlanes +// lighting). The only structural change is the version + bindless extension +// — sampler access in the fragment stage is unchanged at the GLSL level. + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in uvec4 aPacked0; +layout(location = 3) in uvec4 aPacked1; +layout(location = 4) in uvec4 aPacked2; +layout(location = 5) in uvec4 aPacked3; + +uniform mat4 uView; +uniform mat4 uProjection; + +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; + +out vec2 vBaseUV; +out vec3 vWorldNormal; +out vec3 vWorldPos; +out vec3 vLightingRGB; +out vec4 vOverlay0; +out vec4 vOverlay1; +out vec4 vOverlay2; +out vec4 vRoad0; +out vec4 vRoad1; +flat out float vBaseTexIdx; + +const float MIN_FACTOR = 0.0; + +vec4 unpackOverlayLayer(uint texIdxU, uint alphaIdxU, uint rotIdx, vec2 baseUV) { + float texIdx = float(texIdxU); + float alphaIdx = float(alphaIdxU); + if (texIdx >= 254.0) texIdx = -1.0; + if (alphaIdx >= 254.0) alphaIdx = -1.0; + + vec2 rotatedUV = baseUV; + if (rotIdx == 1u) rotatedUV = vec2(1.0 - baseUV.y, baseUV.x); + else if (rotIdx == 2u) rotatedUV = vec2(1.0 - baseUV.x, 1.0 - baseUV.y); + else if (rotIdx == 3u) rotatedUV = vec2( baseUV.y, 1.0 - baseUV.x); + + return vec4(rotatedUV.x, rotatedUV.y, texIdx, alphaIdx); +} + +void main() { + uint rotOvl0 = (aPacked3.x >> 2u) & 3u; + uint rotOvl1 = (aPacked3.x >> 4u) & 3u; + uint rotOvl2 = (aPacked3.x >> 6u) & 3u; + uint rotRd0 = aPacked3.y & 3u; + uint rotRd1 = (aPacked3.y >> 2u) & 3u; + uint splitDir= (aPacked3.y >> 4u) & 1u; + + int vIdx = gl_VertexID % 6; + int corner = 0; + if (splitDir == 0u) { + // SWtoNE order: BL, BR, TR, BL, TR, TL → corners 0, 1, 2, 0, 2, 3 + if (vIdx == 0) corner = 0; + else if (vIdx == 1) corner = 1; + else if (vIdx == 2) corner = 2; + else if (vIdx == 3) corner = 0; + else if (vIdx == 4) corner = 2; + else corner = 3; + } else { + // SEtoNW order: BL, BR, TL, BR, TR, TL → corners 0, 1, 3, 1, 2, 3 + if (vIdx == 0) corner = 0; + else if (vIdx == 1) corner = 1; + else if (vIdx == 2) corner = 3; + else if (vIdx == 3) corner = 1; + else if (vIdx == 4) corner = 2; + else corner = 3; + } + + vec2 baseUV; + if (corner == 0) baseUV = vec2(0.0, 1.0); + else if (corner == 1) baseUV = vec2(1.0, 1.0); + else if (corner == 2) baseUV = vec2(1.0, 0.0); + else baseUV = vec2(0.0, 0.0); + + vBaseUV = baseUV; + vWorldPos = aPos; + vWorldNormal = normalize(aNormal); + + // Retail AdjustPlanes bake (terrain.vert:124-134 — identical math). + vec3 sunDir = uLights[0].dirAndRange.xyz; + vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w; + float L = max(dot(vWorldNormal, -sunDir), MIN_FACTOR); + vLightingRGB = sunCol * L + uCellAmbient.xyz; + + float baseTex = float(aPacked0.x); + if (baseTex >= 254.0) baseTex = -1.0; + vBaseTexIdx = baseTex; + + vOverlay0 = unpackOverlayLayer(aPacked0.z, aPacked0.w, rotOvl0, baseUV); + vOverlay1 = unpackOverlayLayer(aPacked1.x, aPacked1.y, rotOvl1, baseUV); + vOverlay2 = unpackOverlayLayer(aPacked1.z, aPacked1.w, rotOvl2, baseUV); + vRoad0 = unpackOverlayLayer(aPacked2.x, aPacked2.y, rotRd0, baseUV); + vRoad1 = unpackOverlayLayer(aPacked2.z, aPacked2.w, rotRd1, baseUV); + + gl_Position = uProjection * uView * vec4(aPos, 1.0); +} +``` + +- [ ] **Step 4.3: Verify the shader file ships with the project (build copy)** + +Look at `src/AcDream.App/AcDream.App.csproj`. If shader files use `` with `` or a Glob, the new file will be picked up automatically. If shaders are individually listed, add the new file there. + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo` +Expected: 0 errors. (No code touched it; should compile clean.) + +- [ ] **Step 4.4: Commit** + +```bash +git add src/AcDream.App/Rendering/Shaders/terrain_modern.vert +# Also add csproj if it was modified to include the file: +# git add src/AcDream.App/AcDream.App.csproj +git commit -m "$(cat <<'EOF' +phase(N.5b) Task 4: terrain_modern.vert + +Vertex shader for the modern terrain dispatcher. Bit-identical math +to today's terrain.vert (Phase 3c per-cell mesh + Phase G AdjustPlanes +lighting). The only structural change is the version + bindless +extension preamble — sampler access stays a regular sampler2DArray +uniform; bindless-ness is invisible at the GLSL level. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: terrain_modern.frag + +**Goal:** Fragment shader for the modern terrain dispatcher. Bit-identical math to today's `terrain.frag` with the same bindless preamble change. + +**Files:** +- Create: `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` + +- [ ] **Step 5.1: Read today's `terrain.frag`** + +Read `src/AcDream.App/Rendering/Shaders/terrain.frag` end-to-end (149 lines). The new shader is a 1:1 port with the same `#version 460 core` + `#extension GL_ARB_bindless_texture : require` preamble change. + +- [ ] **Step 5.2: Write the new shader** + +Create `src/AcDream.App/Rendering/Shaders/terrain_modern.frag`: + +```glsl +#version 460 core +#extension GL_ARB_bindless_texture : require + +// Phase N.5b: terrain fragment shader on the modern bindless dispatcher. +// Math identical to terrain.frag (Phase 3c per-cell maskBlend3 + +// Phase G fog + lightning flash). uTerrain and uAlpha are bound via +// glProgramUniformHandleARB on the C# side; GLSL sampling is unchanged. + +in vec2 vBaseUV; +in vec3 vWorldNormal; +in vec3 vWorldPos; +in vec3 vLightingRGB; +in vec4 vOverlay0; +in vec4 vOverlay1; +in vec4 vOverlay2; +in vec4 vRoad0; +in vec4 vRoad1; +flat in float vBaseTexIdx; + +out vec4 fragColor; + +uniform sampler2DArray uTerrain; +uniform sampler2DArray uAlpha; + +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; + +const float TILE = 1.0; + +vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) { + float a0 = h0 == 0.0 ? 1.0 : t0.a; + float a1 = h1 == 0.0 ? 1.0 : t1.a; + float a2 = h2 == 0.0 ? 1.0 : t2.a; + float aR = 1.0 - (a0 * a1 * a2); + float aRsafe = max(aR, 1e-6); + a0 = 1.0 - a0; + a1 = 1.0 - a1; + a2 = 1.0 - a2; + vec3 r0 = (a0 * t0.rgb + (1.0 - a0) * a1 * t1.rgb + (1.0 - a1) * a2 * t2.rgb); + return vec4(r0 / aRsafe, aR); +} + +vec4 combineOverlays(vec2 baseUV, vec4 pOverlay0, vec4 pOverlay1, vec4 pOverlay2) { + float h0 = pOverlay0.z < 0.0 ? 0.0 : 1.0; + float h1 = pOverlay1.z < 0.0 ? 0.0 : 1.0; + float h2 = pOverlay2.z < 0.0 ? 0.0 : 1.0; + vec4 t0 = vec4(0.0), t1 = vec4(0.0), t2 = vec4(0.0); + + if (h0 > 0.0) { + t0 = texture(uTerrain, vec3(baseUV * TILE, pOverlay0.z)); + if (pOverlay0.w >= 0.0) { + vec4 a = texture(uAlpha, vec3(pOverlay0.xy, pOverlay0.w)); + t0.a = a.a; + } + } + if (h1 > 0.0) { + t1 = texture(uTerrain, vec3(baseUV * TILE, pOverlay1.z)); + if (pOverlay1.w >= 0.0) { + vec4 a = texture(uAlpha, vec3(pOverlay1.xy, pOverlay1.w)); + t1.a = a.a; + } + } + if (h2 > 0.0) { + t2 = texture(uTerrain, vec3(baseUV * TILE, pOverlay2.z)); + if (pOverlay2.w >= 0.0) { + vec4 a = texture(uAlpha, vec3(pOverlay2.xy, pOverlay2.w)); + t2.a = a.a; + } + } + return maskBlend3(t0, t1, t2, h0, h1, h2); +} + +vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) { + float h0 = pRoad0.z < 0.0 ? 0.0 : 1.0; + float h1 = pRoad1.z < 0.0 ? 0.0 : 1.0; + vec4 result = vec4(0.0); + if (h0 > 0.0) { + result = texture(uTerrain, vec3(baseUV * TILE, pRoad0.z)); + if (pRoad0.w >= 0.0) { + vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w)); + result.a = 1.0 - a0.a; + if (h1 > 0.0 && pRoad1.w >= 0.0) { + vec4 a1 = texture(uAlpha, vec3(pRoad1.xy, pRoad1.w)); + result.a = 1.0 - (a0.a * a1.a); + } + } + } + return result; +} + +vec3 applyFog(vec3 lit, vec3 worldPos) { + int mode = int(uFogParams.w); + if (mode == 0) return lit; + float d = length(worldPos - uCameraAndTime.xyz); + float fogStart = uFogParams.x; + float fogEnd = uFogParams.y; + float span = max(1e-3, fogEnd - fogStart); + float fog = clamp((d - fogStart) / span, 0.0, 1.0); + return mix(lit, uFogColor.xyz, fog); +} + +void main() { + vec4 baseColor = vec4(0.0); + if (vBaseTexIdx >= 0.0) { + baseColor = texture(uTerrain, vec3(vBaseUV * TILE, vBaseTexIdx)); + } + + vec4 overlays = vec4(0.0); + if (vOverlay0.z >= 0.0) + overlays = combineOverlays(vBaseUV, vOverlay0, vOverlay1, vOverlay2); + + vec4 roads = vec4(0.0); + if (vRoad0.z >= 0.0) + roads = combineRoad(vBaseUV, vRoad0, vRoad1); + + vec3 baseMasked = baseColor.rgb * ((1.0 - overlays.a) * (1.0 - roads.a)); + vec3 ovlMasked = overlays.rgb * (overlays.a * (1.0 - roads.a)); + vec3 roadMasked = roads.rgb * roads.a; + vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0); + + vec3 lit = rgb * min(vLightingRGB, vec3(1.0)); + + float flash = uFogParams.z; + lit += flash * vec3(0.6, 0.6, 0.75); + + lit = applyFog(lit, vWorldPos); + + fragColor = vec4(lit, 1.0); +} +``` + +- [ ] **Step 5.3: Build green** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo` +Expected: 0 errors. + +- [ ] **Step 5.4: Commit** + +```bash +git add src/AcDream.App/Rendering/Shaders/terrain_modern.frag +# Add csproj if needed for shader copy +git commit -m "$(cat <<'EOF' +phase(N.5b) Task 5: terrain_modern.frag + +Fragment shader for the modern terrain dispatcher. Bit-identical math +to today's terrain.frag (per-cell maskBlend3 + Phase G fog + lightning +flash). Same #version 460 + GL_ARB_bindless_texture preamble change +as terrain_modern.vert. Sampling syntax unchanged — the bindless-ness +is invisible at the GLSL level. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: TerrainModernRenderer + +**Goal:** The dispatcher class. Wires `TerrainSlotAllocator` + GL state + bindless atlas handle uniforms + DEIC dispatch via `glMultiDrawElementsIndirect`. Replaces `TerrainChunkRenderer` (drop-in interface). + +**Files:** +- Create: `src/AcDream.App/Rendering/TerrainModernRenderer.cs` + +**Depends on:** Task 1 (`TerrainAtlas.GetBindlessHandles`), Task 2 (`TerrainSlotAllocator`), Task 4 + 5 (shaders). + +- [ ] **Step 6.1: Skim existing pattern** + +Read these files for the pattern this code mirrors: +- `src/AcDream.App/Rendering/TerrainChunkRenderer.cs` — current per-chunk pattern (the API surface to match) +- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — N.5's modern dispatcher (the SSBO + indirect pattern) +- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainRenderManager.cs` lines 645-902 — WB's terrain dispatcher (the slot allocator + multi-draw indirect pattern; GL calls match what we want) + +- [ ] **Step 6.2: Implement TerrainModernRenderer** + +Create `src/AcDream.App/Rendering/TerrainModernRenderer.cs`: + +```csharp +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.Core.Terrain; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// Phase N.5b modern terrain dispatcher. Single global VBO/EBO with a slot +/// allocator (one slot per landblock, 384 verts × 40 bytes = 15,360 bytes +/// per slot). Per-frame: build a DrawElementsIndirectCommand array from +/// visible slots, upload, dispatch via glMultiDrawElementsIndirect. Atlas +/// textures bound via bindless handles set per-frame as sampler uniforms. +/// +/// Total ~6-8 GL calls per frame for terrain regardless of visible +/// landblock count. +/// +public sealed unsafe class TerrainModernRenderer : IDisposable +{ + private const int VertsPerLandblock = LandblockMesh.VerticesPerLandblock; // 384 + private const int IndicesPerLandblock = VertsPerLandblock; + private const int VertexSize = 40; // sizeof(TerrainVertex) + private const int IndexSize = sizeof(uint); + private const float LandblockSize = LandblockMesh.LandblockSize; // 192 + + private readonly GL _gl; + private readonly BindlessSupport _bindless; + private readonly Shader _shader; + private readonly TerrainAtlas _atlas; + + private readonly TerrainSlotAllocator _alloc; + + // Per-slot live data (index by slot integer; null entries are unused slots). + private SlotData?[] _slots; + + // Reverse map: landblockId -> slot, for RemoveLandblock and replacement. + private readonly Dictionary _idToSlot = new(); + + // GPU buffers. + private uint _globalVao; + private uint _globalVbo; + private uint _globalEbo; + private uint _indirectBuffer; + private int _indirectCapacity; + + // Cached sampler-uniform locations (matrix uniforms are set by name via Shader.SetMatrix4). + private int _uTerrainLoc; + private int _uAlphaLoc; + + // Reusable per-frame buffers. + private readonly List _visibleSlots = new(); + private DrawElementsIndirectCommand[] _deicScratch = Array.Empty(); + + // Diag. + public int LoadedSlots => _alloc.LoadedCount; + public int VisibleSlots => _visibleSlots.Count; + public int CapacitySlots => _alloc.Capacity; + + public TerrainModernRenderer( + GL gl, + BindlessSupport bindless, + Shader shader, + TerrainAtlas atlas, + int initialSlotCapacity = 64) + { + _gl = gl; + _bindless = bindless; + _shader = shader; + _atlas = atlas; + _alloc = new TerrainSlotAllocator(initialSlotCapacity); + _slots = new SlotData?[initialSlotCapacity]; + + _uTerrainLoc = _gl.GetUniformLocation(_shader.Program, "uTerrain"); + _uAlphaLoc = _gl.GetUniformLocation(_shader.Program, "uAlpha"); + + _globalVao = _gl.GenVertexArray(); + _globalVbo = _gl.GenBuffer(); + _globalEbo = _gl.GenBuffer(); + AllocateGpuBuffers(initialSlotCapacity); + ConfigureVao(); + + _indirectBuffer = _gl.GenBuffer(); + } + + public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin) + { + ArgumentNullException.ThrowIfNull(meshData); + if (meshData.Vertices.Length != VertsPerLandblock) + throw new ArgumentException( + $"Expected {VertsPerLandblock} vertices, got {meshData.Vertices.Length}", + nameof(meshData)); + + if (_idToSlot.ContainsKey(landblockId)) + RemoveLandblock(landblockId); + + int slot = _alloc.Allocate(out var needsGrow); + if (needsGrow) + { + int newCap = Math.Max(_alloc.Capacity * 2, slot + 1); + EnsureCapacity(newCap); + } + + // Bake worldOrigin into vertex positions; capture min/max Z for AABB. + var bakedVerts = new TerrainVertex[VertsPerLandblock]; + float zMin = float.MaxValue, zMax = float.MinValue; + for (int i = 0; i < VertsPerLandblock; i++) + { + var v = meshData.Vertices[i]; + var worldPos = v.Position + worldOrigin; + bakedVerts[i] = new TerrainVertex(worldPos, v.Normal, v.Data0, v.Data1, v.Data2, v.Data3); + if (worldPos.Z < zMin) zMin = worldPos.Z; + if (worldPos.Z > zMax) zMax = worldPos.Z; + } + if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; } + + // Bake baseVertex into indices on the CPU side (driver-portable pattern). + uint baseVertex = (uint)(slot * VertsPerLandblock); + var bakedIndices = new uint[IndicesPerLandblock]; + for (int i = 0; i < IndicesPerLandblock; i++) + bakedIndices[i] = meshData.Indices[i] + baseVertex; + + // glBufferSubData into the slot's VBO + EBO regions. + nint vboByteOffset = (nint)(slot * VertsPerLandblock * VertexSize); + nint eboByteOffset = (nint)(slot * IndicesPerLandblock * IndexSize); + + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo); + fixed (TerrainVertex* p = bakedVerts) + { + _gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboByteOffset, + (nuint)(VertsPerLandblock * VertexSize), p); + } + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo); + fixed (uint* p = bakedIndices) + { + _gl.BufferSubData(BufferTargetARB.ElementArrayBuffer, eboByteOffset, + (nuint)(IndicesPerLandblock * IndexSize), p); + } + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0); + + _slots[slot] = new SlotData + { + LandblockId = landblockId, + WorldOrigin = worldOrigin, + FirstIndex = (uint)(slot * IndicesPerLandblock), + IndexCount = IndicesPerLandblock, + AabbMin = new Vector3(worldOrigin.X, worldOrigin.Y, zMin), + AabbMax = new Vector3(worldOrigin.X + LandblockSize, worldOrigin.Y + LandblockSize, zMax), + }; + _idToSlot[landblockId] = slot; + } + + public void RemoveLandblock(uint landblockId) + { + if (!_idToSlot.TryGetValue(landblockId, out var slot)) + return; + _idToSlot.Remove(landblockId); + _slots[slot] = null; + _alloc.Free(slot); + // No GPU clear: the per-frame DEIC array won't reference this slot. + } + + public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null) + { + if (_alloc.LoadedCount == 0) return; + + // Build visible slot list with per-slot frustum cull. + _visibleSlots.Clear(); + for (int slot = 0; slot < _slots.Length; slot++) + { + var data = _slots[slot]; + if (data is null) continue; + if (frustum is not null && data.LandblockId != neverCullLandblockId) + { + if (!FrustumCuller.IsAabbVisible(frustum.Value, data.AabbMin, data.AabbMax)) + continue; + } + _visibleSlots.Add(slot); + } + if (_visibleSlots.Count == 0) return; + + // Build DEIC array. + if (_deicScratch.Length < _visibleSlots.Count) + _deicScratch = new DrawElementsIndirectCommand[Math.Max(_visibleSlots.Count, 64)]; + for (int i = 0; i < _visibleSlots.Count; i++) + { + var data = _slots[_visibleSlots[i]]!; + _deicScratch[i] = new DrawElementsIndirectCommand + { + Count = (uint)data.IndexCount, + InstanceCount = 1u, + FirstIndex = data.FirstIndex, + BaseVertex = 0, // baked into indices on upload + BaseInstance = 0, + }; + } + + // Grow indirect buffer if needed. + if (_visibleSlots.Count > _indirectCapacity) + { + _indirectCapacity = Math.Max(64, _visibleSlots.Count * 2); + _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _indirectBuffer); + _gl.BufferData(GLEnum.DrawIndirectBuffer, + (nuint)(_indirectCapacity * sizeof(DrawElementsIndirectCommand)), + null, GLEnum.DynamicDraw); + } + else + { + _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _indirectBuffer); + } + + // Upload DEIC array. + fixed (DrawElementsIndirectCommand* p = _deicScratch) + { + _gl.BufferSubData(GLEnum.DrawIndirectBuffer, 0, + (nuint)(_visibleSlots.Count * sizeof(DrawElementsIndirectCommand)), p); + } + + // Bind shader + uniforms + atlas handles. + _shader.Use(); + _shader.SetMatrix4("uView", camera.View); + _shader.SetMatrix4("uProjection", camera.Projection); + + var (terrainHandle, alphaHandle) = _atlas.GetBindlessHandles(); + _bindless.SetSamplerHandleUniform(_shader.Program, _uTerrainLoc, terrainHandle); + _bindless.SetSamplerHandleUniform(_shader.Program, _uAlphaLoc, alphaHandle); + + _gl.BindVertexArray(_globalVao); + _gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit); + _gl.MultiDrawElementsIndirect( + PrimitiveType.Triangles, DrawElementsType.UnsignedInt, + (void*)0, + (uint)_visibleSlots.Count, + (uint)sizeof(DrawElementsIndirectCommand)); + _gl.BindVertexArray(0); + _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); + } + + public void Dispose() + { + _gl.DeleteVertexArray(_globalVao); + _gl.DeleteBuffer(_globalVbo); + _gl.DeleteBuffer(_globalEbo); + _gl.DeleteBuffer(_indirectBuffer); + } + + // ---------------------------------------------------------------- + // Private helpers + // ---------------------------------------------------------------- + + private void AllocateGpuBuffers(int capacitySlots) + { + nuint vboBytes = (nuint)(capacitySlots * VertsPerLandblock * VertexSize); + nuint eboBytes = (nuint)(capacitySlots * IndicesPerLandblock * IndexSize); + + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo); + _gl.BufferData(BufferTargetARB.ArrayBuffer, vboBytes, null, BufferUsageARB.DynamicDraw); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); + + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo); + _gl.BufferData(BufferTargetARB.ElementArrayBuffer, eboBytes, null, BufferUsageARB.DynamicDraw); + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0); + } + + private void ConfigureVao() + { + _gl.BindVertexArray(_globalVao); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo); + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo); + + uint stride = (uint)VertexSize; + + // location 0: Position + _gl.EnableVertexAttribArray(0); + _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0); + // location 1: Normal + _gl.EnableVertexAttribArray(1); + _gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float))); + // locations 2-5: Data0..Data3 (uvec4 byte attributes) + nint dataOffset = 6 * sizeof(float); + _gl.EnableVertexAttribArray(2); + _gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset); + _gl.EnableVertexAttribArray(3); + _gl.VertexAttribIPointer(3, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 4)); + _gl.EnableVertexAttribArray(4); + _gl.VertexAttribIPointer(4, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 8)); + _gl.EnableVertexAttribArray(5); + _gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12)); + + _gl.BindVertexArray(0); + } + + private void EnsureCapacity(int newCapacity) + { + if (newCapacity <= _alloc.Capacity) return; + + // Allocate new VBO + EBO at new size; copy old contents; swap; recreate VAO. + uint newVbo = _gl.GenBuffer(); + uint newEbo = _gl.GenBuffer(); + + nuint newVboBytes = (nuint)(newCapacity * VertsPerLandblock * VertexSize); + nuint newEboBytes = (nuint)(newCapacity * IndicesPerLandblock * IndexSize); + nuint oldVboBytes = (nuint)(_alloc.Capacity * VertsPerLandblock * VertexSize); + nuint oldEboBytes = (nuint)(_alloc.Capacity * IndicesPerLandblock * IndexSize); + + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, newVbo); + _gl.BufferData(BufferTargetARB.ArrayBuffer, newVboBytes, null, BufferUsageARB.DynamicDraw); + _gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalVbo); + _gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newVbo); + _gl.CopyBufferSubData(CopyBufferSubDataTarget.CopyReadBuffer, CopyBufferSubDataTarget.CopyWriteBuffer, + 0, 0, oldVboBytes); + _gl.DeleteBuffer(_globalVbo); + _globalVbo = newVbo; + + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, newEbo); + _gl.BufferData(BufferTargetARB.ElementArrayBuffer, newEboBytes, null, BufferUsageARB.DynamicDraw); + _gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalEbo); + _gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newEbo); + _gl.CopyBufferSubData(CopyBufferSubDataTarget.CopyReadBuffer, CopyBufferSubDataTarget.CopyWriteBuffer, + 0, 0, oldEboBytes); + _gl.DeleteBuffer(_globalEbo); + _globalEbo = newEbo; + + // Recreate VAO with new buffer bindings. + _gl.DeleteVertexArray(_globalVao); + _globalVao = _gl.GenVertexArray(); + ConfigureVao(); + + // Grow slot tracking array. + Array.Resize(ref _slots, newCapacity); + _alloc.GrowTo(newCapacity); + } + + private sealed class SlotData + { + public uint LandblockId; + public Vector3 WorldOrigin; + public uint FirstIndex; + public int IndexCount; + public Vector3 AabbMin; + public Vector3 AabbMax; + } +} +``` + +- [ ] **Step 6.3: Add `SetSamplerHandleUniform` helper to BindlessSupport** + +The renderer calls `_bindless.SetSamplerHandleUniform(...)` which doesn't exist yet. Add it to `src/AcDream.App/Rendering/Wb/BindlessSupport.cs`: + +After the `MakeNonResident` method (around line 46), add: + +```csharp +/// +/// Set a sampler-typed uniform from a 64-bit bindless handle. Uses +/// glProgramUniformHandleARB so it doesn't require the program to be bound. +/// +public void SetSamplerHandleUniform(uint program, int location, ulong handle) +{ + _ext.ProgramUniformHandle(program, location, handle); +} +``` + +- [ ] **Step 6.4: Build green** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo` +Expected: 0 errors. (`Silk.NET.OpenGL.Extensions.ARB.ArbBindlessTexture` already provides `ProgramUniformHandle`.) + +If the Silk.NET method name differs (e.g. `ProgramUniformHandleARB` vs `ProgramUniformHandle`), check `using Silk.NET.OpenGL.Extensions.ARB;` IntelliSense and use the correct name. + +- [ ] **Step 6.5: Commit** + +```bash +git add src/AcDream.App/Rendering/TerrainModernRenderer.cs src/AcDream.App/Rendering/Wb/BindlessSupport.cs +git commit -m "$(cat <<'EOF' +phase(N.5b) Task 6: TerrainModernRenderer + +The new terrain dispatcher. Single global VBO/EBO with a slot +allocator (one slot per landblock, 384 verts × 40 bytes per slot). +Per-frame: build DEIC array from visible slots, upload, dispatch +via glMultiDrawElementsIndirect. Atlas textures bound via bindless +handles set per-frame as sampler uniforms. + +Total ~6-8 GL calls per frame for terrain regardless of visible +landblock count (vs today's per-LB binds at radius=2 → ~25 calls, +radius=5 → ~121 calls). + +API mirrors TerrainChunkRenderer so GameWindow integration in T8 is +a drop-in field+ctor swap. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: TerrainModernConformanceTests + +**Goal:** Z-conformance sentinel for issue #51's bug class. Sweeps ~10 representative landblocks × ~100 sample points; asserts `|meshTriZ - TerrainSurface.SampleZFromHeightmap| < 0.001m`. + +**Files:** +- Create: `tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs` + +**Independence note:** This test uses `LandblockMesh.Build` directly (the source-of-truth generator that `TerrainModernRenderer` consumes internally). The test runs without GL and is independent of T6 — it can land in parallel with T1, T2, T4, T5. + +- [ ] **Step 7.1: Read the existing `ClientConformanceTests.cs` for the dat-loading pattern** + +Run: `cat tests/AcDream.Core.Tests/Terrain/ClientConformanceTests.cs | head -80` + +This shows the existing pattern for loading dat heightmap data in tests. Use the same `DatCollection` setup + `Region` fetch pattern. + +- [ ] **Step 7.2: Write the conformance test** + +Create `tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs`: + +```csharp +using System; +using System.Collections.Generic; +using System.IO; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Terrain; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Terrain; + +/// +/// Phase N.5b Z-conformance sentinel: proves that the visual terrain mesh +/// produced by agrees with the physics-side +/// at arbitrary (X, Y) +/// within 1 mm. This is the exact bug class issue #51 names — if a future +/// refactor silently changes formula or vertex layout in either path, +/// this test fires before the player floats above (or sinks below) the +/// visible ground. +/// +public class TerrainModernConformanceTests +{ + private readonly ITestOutputHelper _out; + + public TerrainModernConformanceTests(ITestOutputHelper output) => _out = output; + + private static readonly (string name, uint lbX, uint lbY)[] RepresentativeLandblocks = + { + ("Holtburg flat 0xA9B0", 0xA9, 0xB0), + ("Holtburg sloped 0xA9B1", 0xA9, 0xB1), + ("Foundry-area 0x8080", 0x80, 0x80), + ("Cragstone 0xCB99", 0xCB, 0x99), + ("Direlands sample 0xC040", 0xC0, 0x40), + ("MapOrigin 0x0000", 0x00, 0x00), + ("Mid-map 0x7F7F", 0x7F, 0x7F), + ("MapCorner 0xFEFE", 0xFE, 0xFE), + ("Subway outdoor 0x0185", 0x01, 0x85), + ("North continent 0x4D96", 0x4D, 0x96), // worst-case landblock from divergence test + }; + + [Fact] + public void VisualMeshZ_AgreesWith_PhysicsZ_WithinOneMillimeter() + { + var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + if (!Directory.Exists(datDir)) + { + _out.WriteLine($"SKIP: dat directory not found at {datDir}"); + return; + } + + using var dats = new DatCollection(datDir); + var region = dats.Get(0x13000000u); + Assert.NotNull(region); + var heightTable = region.LandDefs.LandHeightTable; + + long totalSamples = 0; + long totalLandblocksTested = 0; + double maxDelta = 0; + (string name, uint lbX, uint lbY, float lx, float ly, float meshZ, float physicsZ) worstCase = default; + + var rng = new Random(seed: 42); // fixed seed for reproducible sample distribution + + foreach (var (name, lbX, lbY) in RepresentativeLandblocks) + { + uint landblockId = (lbX << 24) | (lbY << 16) | 0xFFFFu; + var landblock = dats.Get(landblockId); + if (landblock is null) + { + _out.WriteLine($" skipped {name}: dat not found (probably water-only)"); + continue; + } + totalLandblocksTested++; + + // Compute mesh via the source-of-truth generator. Empty surfaceCache + // is fine — test only cares about vertex Z values. + var ctx = TerrainBlendingContext.Empty; // see Note below if this constructor doesn't exist + var surfaceCache = new Dictionary(); + var meshData = LandblockMesh.Build(landblock, lbX, lbY, heightTable, ctx, surfaceCache); + + // Sample 100 (localX, localY) points uniformly + edge cases. + for (int s = 0; s < 100; s++) + { + float lx = (float)rng.NextDouble() * 192f; + float ly = (float)rng.NextDouble() * 192f; + + float meshZ = SampleMeshZ(meshData, lx, ly); + float physicsZ = TerrainSurface.SampleZFromHeightmap( + landblock.Height, heightTable, lbX, lbY, lx, ly); + + double delta = Math.Abs(meshZ - physicsZ); + if (delta > maxDelta) + { + maxDelta = delta; + worstCase = (name, lbX, lbY, lx, ly, meshZ, physicsZ); + } + totalSamples++; + Assert.True(delta < 0.001, + $"Mesh Z disagrees with physics Z at lb=0x{lbX:X2}{lbY:X2} ({name}) " + + $"local=({lx:F2},{ly:F2}): meshZ={meshZ:F4} physicsZ={physicsZ:F4} delta={delta:F4}m"); + } + } + + _out.WriteLine($"=== Phase N.5b conformance sweep ==="); + _out.WriteLine($"Landblocks tested: {totalLandblocksTested}/{RepresentativeLandblocks.Length}"); + _out.WriteLine($"Total samples: {totalSamples}"); + _out.WriteLine($"Max |delta|: {maxDelta * 1000:F4} mm (tolerance: 1.0 mm)"); + if (totalSamples > 0) + _out.WriteLine($"Worst case: {worstCase.name} local=({worstCase.lx:F2},{worstCase.ly:F2}) " + + $"meshZ={worstCase.meshZ:F4} physicsZ={worstCase.physicsZ:F4}"); + + Assert.True(totalLandblocksTested >= 5, + $"Expected at least 5 representative landblocks loadable; got {totalLandblocksTested}."); + } + + /// + /// Sample the mesh's triangle-interpolated Z at (localX, localY). Walks + /// the mesh's triangles (3 indices each), tests point-in-triangle in 2D, + /// and barycentric-interpolates Z from the matching triangle's three Zs. + /// + private static float SampleMeshZ(LandblockMeshData mesh, float lx, float ly) + { + for (int triBase = 0; triBase < mesh.Indices.Length; triBase += 3) + { + var v0 = mesh.Vertices[mesh.Indices[triBase + 0]]; + var v1 = mesh.Vertices[mesh.Indices[triBase + 1]]; + var v2 = mesh.Vertices[mesh.Indices[triBase + 2]]; + + // Barycentric coords for (lx, ly) wrt triangle v0/v1/v2 in 2D. + float denom = (v1.Position.Y - v2.Position.Y) * (v0.Position.X - v2.Position.X) + + (v2.Position.X - v1.Position.X) * (v0.Position.Y - v2.Position.Y); + if (Math.Abs(denom) < 1e-9f) continue; + + float a = ((v1.Position.Y - v2.Position.Y) * (lx - v2.Position.X) + + (v2.Position.X - v1.Position.X) * (ly - v2.Position.Y)) / denom; + float b = ((v2.Position.Y - v0.Position.Y) * (lx - v2.Position.X) + + (v0.Position.X - v2.Position.X) * (ly - v2.Position.Y)) / denom; + float c = 1f - a - b; + + // Inside test with epsilon for boundary stability. + const float eps = 1e-4f; + if (a >= -eps && b >= -eps && c >= -eps) + return a * v0.Position.Z + b * v1.Position.Z + c * v2.Position.Z; + } + + // Should not happen for valid mesh + in-bounds (lx, ly). + throw new InvalidOperationException( + $"No triangle found containing local=({lx:F2},{ly:F2}); mesh has {mesh.Indices.Length / 3} triangles."); + } +} +``` + +**Note on `TerrainBlendingContext.Empty`:** if this static doesn't exist, construct a minimal one: + +```csharp +var ctx = new TerrainBlendingContext( + terrainTypeToLayer: new Dictionary(), + cornerAlphaLayers: Array.Empty(), + sideAlphaLayers: Array.Empty(), + roadAlphaLayers: Array.Empty(), + cornerAlphaTCodes: Array.Empty(), + sideAlphaTCodes: Array.Empty(), + roadAlphaRCodes: Array.Empty(), + roadLayer: SurfaceInfo.None); +``` + +(Check `src/AcDream.Core/Terrain/TerrainBlendingContext.cs` for the actual signature.) + +- [ ] **Step 7.3: Run the conformance test** + +Run: `dotnet test --filter "FullyQualifiedName~TerrainModernConformanceTests" --nologo --logger "console;verbosity=detailed"` + +Expected outcomes: +- If dat dir present: PASS with `Max |delta|: <1.0 mm` printed. +- If dat dir absent: PASS with `SKIP: dat directory not found` (test gracefully skips). + +If the test FAILS with a delta > 1mm, the visual mesh and physics surface have drifted — investigate before proceeding. + +- [ ] **Step 7.4: Commit** + +```bash +git add tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs +git commit -m "$(cat <<'EOF' +phase(N.5b) Task 7: TerrainModernConformanceTests + +Z-conformance sentinel for issue #51's bug class. Sweeps 10 +representative landblocks × 100 sample points (uniform random in +local 0..192 with fixed seed). For each point: compute meshTriZ +via barycentric interpolation in the matching triangle of the +LandblockMesh.Build output; compute physicsZ via +TerrainSurface.SampleZFromHeightmap; assert |delta| < 0.001m. + +Catches any silent formula or vertex-layout drift between the +visual and physics paths. Skips gracefully if ACDREAM_DAT_DIR +isn't set (CI without dat data). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: GameWindow integration + +**Goal:** Swap `TerrainChunkRenderer` → `TerrainModernRenderer` at the field declaration + construction site. Wire `[TERRAIN-DIAG]` rollup callback. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` + +**Depends on:** Task 6. + +- [ ] **Step 8.1: Locate the field + ctor + diag wiring** + +```bash +grep -n "TerrainChunkRenderer\|_terrain" src/AcDream.App/Rendering/GameWindow.cs | head -20 +``` + +The field declaration is at line 21; the ctor is at line 1391. The diag rollup pattern lives near the existing `[WB-DIAG]` writes — search for `WB-DIAG`. + +- [ ] **Step 8.2: Swap field type** + +In `src/AcDream.App/Rendering/GameWindow.cs:21`, change: + +```csharp +private TerrainChunkRenderer? _terrain; +``` + +to: + +```csharp +private TerrainModernRenderer? _terrain; +``` + +- [ ] **Step 8.3: Swap ctor call (and pass BindlessSupport to TerrainAtlas)** + +At line 1391: + +```csharp +_terrain = new TerrainChunkRenderer(_gl, _shader, terrainAtlas); +``` + +Becomes: + +```csharp +_terrain = new TerrainModernRenderer(_gl, _bindless, _terrainModernShader, terrainAtlas); +``` + +(The `_bindless` field already exists from N.5; the shader field name may need to be created/loaded — see step 8.4.) + +You also need to ensure `terrainAtlas` was constructed with `BindlessSupport`. Find the `TerrainAtlas.Build(gl, dats)` call upstream and change to `TerrainAtlas.Build(gl, dats, _bindless)`. + +- [ ] **Step 8.4: Load the new shader** + +Find where `terrain.vert/.frag` are currently loaded into a `Shader` object. Add a parallel load for `terrain_modern.vert/.frag` into a new `_terrainModernShader` field. Pattern should mirror how `mesh_modern` shaders were loaded in N.5 (search GameWindow for `mesh_modern` to find the template). + +- [ ] **Step 8.5: Add `[TERRAIN-DIAG]` rollup** + +Find where `[WB-DIAG]` is logged. Add a parallel `[TERRAIN-DIAG]` line: + +```csharp +Console.WriteLine( + $"[TERRAIN-DIAG] cpu_ms={terrainCpuMedianMs:F2}/{terrainCpu95thMs:F2} " + + $"draws={_terrain?.VisibleSlots ?? 0}/frame " + + $"visible={_terrain?.VisibleSlots ?? 0} " + + $"loaded={_terrain?.LoadedSlots ?? 0} " + + $"capacity={_terrain?.CapacitySlots ?? 0}"); +``` + +To capture `terrainCpuMedianMs` / `terrainCpu95thMs`, wrap the `_terrain.Draw(...)` call in a `Stopwatch` and accumulate samples into a 5-second rolling buffer. Mirror the existing `[WB-DIAG]` accumulator (search GameWindow for `Stopwatch` + `cpu_ms`). + +- [ ] **Step 8.6: Build + run the client** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo` +Expected: 0 errors. + +Launch the client (PowerShell): + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +$env:ACDREAM_WB_DIAG = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath launch.log +``` + +Wait ~10 seconds for in-world. Confirm: +- Terrain renders (no black ground) +- `launch.log` contains `[TERRAIN-DIAG]` lines + +If terrain is black or missing, check: +- `[WB-DIAG]` — bindless capability detected? +- Atlas handle nonzero? +- `glGetError()` after `glMultiDrawElementsIndirect`? + +- [ ] **Step 8.7: Commit (initial integration; visual gate is next)** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "$(cat <<'EOF' +phase(N.5b): wire TerrainModernRenderer into GameWindow + +Swap TerrainChunkRenderer → TerrainModernRenderer (drop-in: same +AddLandblock/RemoveLandblock/Draw interface). Pass BindlessSupport +to TerrainAtlas.Build so GetBindlessHandles() is callable. Load the +new terrain_modern shader pair and pass to the renderer ctor. Add +[TERRAIN-DIAG] rollup mirroring the existing [WB-DIAG] pattern. + +Visual verification at four scenes (Holtburg flat + sloped, Foundry, +sloped landblock) is the next gate. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## USER VERIFICATION GATE — visual checks + +**Block here. Do not proceed to T9/T10 until the user confirms all checks at all four scenes.** + +User runs the client per the launch command in step 8.6, drives the character through: + +1. **Holtburg town** (~0xA9B0) +2. **Holtburg sloped landblock** (~0xA9B1) +3. **Foundry-area** (~0x80xx) +4. **Any visibly-sloped outdoor landblock** + +At each scene confirm: + +1. ✓ No cell-boundary wobble (load-bearing #51 sentinel) +2. ✓ No missing chunks / black holes +3. ✓ No texture seams at landblock edges +4. ✓ No z-fighting +5. ✓ `[TERRAIN-DIAG] visible=N` consistent with scene; renderer visibly using indirect dispatch (no per-LB calls) +6. ✓ `[TERRAIN-DIAG] cpu_ms` at radius=5 ≥10% lower than the recorded baseline + +If any check fails, fix in place, re-verify, repeat. Only after **all six checks pass at all four scenes** proceed to Tasks 9 + 10. + +--- + +## Task 9: Delete legacy + +**Goal:** Remove the now-unused `TerrainChunkRenderer`, `TerrainRenderer`, and the old shader files. + +**Files:** +- Delete: `src/AcDream.App/Rendering/TerrainChunkRenderer.cs` +- Delete: `src/AcDream.App/Rendering/TerrainRenderer.cs` +- Delete: `src/AcDream.App/Rendering/Shaders/terrain.vert` +- Delete: `src/AcDream.App/Rendering/Shaders/terrain.frag` + +- [ ] **Step 9.1: Delete the files** + +```bash +git rm src/AcDream.App/Rendering/TerrainChunkRenderer.cs +git rm src/AcDream.App/Rendering/TerrainRenderer.cs +git rm src/AcDream.App/Rendering/Shaders/terrain.vert +git rm src/AcDream.App/Rendering/Shaders/terrain.frag +``` + +- [ ] **Step 9.2: Build green (verify nothing else referenced these)** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo` +Expected: 0 errors. + +If references break in unexpected places, restore the files (`git checkout HEAD -- ...`) and find/delete the references first, then re-attempt. + +- [ ] **Step 9.3: Run the full N.5 + N.5b test filter to confirm nothing regressed** + +Run: + +```bash +dotnet test --filter "FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~TerrainBlending|FullyQualifiedName~LandblockMesh|FullyQualifiedName~SplitFormulaDivergence" --nologo +``` + +Expected: all green. + +- [ ] **Step 9.4: Commit** + +```bash +git commit -m "$(cat <<'EOF' +phase(N.5b): retire legacy terrain renderers + +Deletes: +- TerrainChunkRenderer.cs (454 lines, replaced by TerrainModernRenderer) +- TerrainRenderer.cs (247 lines, older sibling, no production users) +- terrain.vert / terrain.frag (replaced by terrain_modern.{vert,frag}) + +The modern path is now the only path. Mirror N.5's mandatory-modern +amendment: missing GL_ARB_bindless_texture throws NotSupportedException +at startup (already in place via the BindlessSupport.TryCreate gate). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 10: Roadmap + ISSUES + memory + perf baseline + +**Goal:** Close out the phase. Update the roadmap, close issue #51, write the memory file, capture perf numbers in a baseline doc. + +**Files:** +- Modify: `docs/plans/2026-04-11-roadmap.md` +- Modify: `docs/ISSUES.md` +- Modify: `CLAUDE.md` +- Create: `docs/plans/2026-05-09-phase-n5b-perf-baseline.md` +- Create: `~/.claude/projects/C--Users-erikn-source-repos-acdream/memory/project_phase_n5b_state.md` + +- [ ] **Step 10.1: Roadmap entry** + +Read `docs/plans/2026-04-11-roadmap.md`. Add an N.5b row to the "Shipped" table (mirror the N.5 row's format). Remove "terrain on modern path" from the N.6 scope notes. + +- [ ] **Step 10.2: Close issue #51** + +In `docs/ISSUES.md`, move issue #51 from the OPEN section to "Recently closed" with the SHIP commit SHA. Note: the resolution was Path C (kept retail's formula via `LandblockMesh.Build`; never adopted WB's formula). + +- [ ] **Step 10.3: Update CLAUDE.md "WB integration cribs"** + +Add an entry under the existing "WB integration cribs" bullet list: + +```markdown +- `src/AcDream.App/Rendering/TerrainModernRenderer.cs` — terrain dispatcher + on N.5's modern primitives. Mirrors WB's `TerrainRenderManager` pattern + (single global VBO/EBO + slot allocator + `glMultiDrawElementsIndirect`) + but driven by acdream's `LandblockMesh.Build` so retail's `FSplitNESW` + formula is preserved (issue #51). ~6-8 GL calls/frame for terrain + regardless of scene size. +``` + +- [ ] **Step 10.4: Write the perf baseline doc** + +Create `docs/plans/2026-05-09-phase-n5b-perf-baseline.md` with the before/after numbers from the user verification gate: + +```markdown +# Phase N.5b — terrain perf baseline + +## Test scene +- Holtburg town (~0xA9B0), radius=5, default settings. +- Captured 5-second `[TERRAIN-DIAG]` rollup median + 95th. + +## Before (TerrainChunkRenderer) +- Terrain GL calls / frame: +- CPU dispatcher cpu_ms median: +- CPU dispatcher cpu_ms 95th : + +## After (TerrainModernRenderer) +- Terrain GL calls / frame: +- CPU dispatcher cpu_ms median: +- CPU dispatcher cpu_ms 95th : + +## Reduction +- GL calls: (~Z% reduction) +- CPU median: ms → ms (~Z% reduction) + +## Acceptance +- Acceptance criterion 5 (≥10% CPU reduction at radius=5): +``` + +- [ ] **Step 10.5: Write the memory file** + +Create `~/.claude/projects/C--Users-erikn-source-repos-acdream/memory/project_phase_n5b_state.md`: + +```markdown +--- +name: "Project: Phase N.5b state (shipped 2026-MM-DD)" +description: N.5b lifted terrain rendering onto bindless + multi-draw indirect via Path C (WB's renderer pattern, acdream's LandblockMesh.Build for retail formula compliance). ~6-8 GL calls/frame for terrain. Closes issue #51. +type: project +--- +**Phase N.5b — Terrain on the Modern Rendering Path — shipped 2026-MM-DD.** + +`TerrainModernRenderer` replaces `TerrainChunkRenderer` (deleted along +with `TerrainRenderer` + `terrain.vert/.frag`). Single global VBO/EBO +with slot allocator (one slot per landblock); per-frame DEIC array +upload + `glMultiDrawElementsIndirect`; bindless atlas handles set +per-frame as sampler uniforms. + +**Path C** (chosen during brainstorm): mirror WB's renderer pattern +but consume `LandblockMesh.Build` (which uses retail's `FSplitNESW` +formula). Path A killed by 49.98% measured divergence between WB's +formula and retail's at retail addr `00531d10`. Path B (fork-patch +WB) rejected for permanent maintenance burden. + +Closes issue #51 (visual ↔ physics terrain Z agreement). + +**Why:** N.5b completes the rendering modernization for outdoor +content. Together with N.5 entity rendering, every visible +gameplay-area surface now flows through `glMultiDrawElementsIndirect`. +EnvCells (interiors), sky, particles still on legacy renderers +pending later phases. + +**How to apply:** when working on terrain rendering, the modern path +is now the only path. The split formula is locked to retail's +`FSplitNESW` via `TerrainBlending.CalculateSplitDirection`; do NOT +substitute WB's `TerrainUtils.CalculateSplitDirection` (49.98% wrong +per the divergence test). + +## Gotchas surfaced during N.5b implementation + +(Fill in any high-value, non-obvious lessons that surfaced during +implementation. If nothing surfaced beyond what N.5's gotchas +already cover, note that explicitly.) +``` + +Then add a one-line entry to the memory index at `~/.claude/projects/C--Users-erikn-source-repos-acdream/memory/MEMORY.md`: + +```markdown +- [Project: Phase N.5b state](project_phase_n5b_state.md) — N.5b SHIPPED YYYY-MM-DD. Terrain on bindless + multi-draw indirect via Path C. Closes #51. +``` + +- [ ] **Step 10.6: Final SHIP commit** + +```bash +git add docs/plans/2026-04-11-roadmap.md docs/ISSUES.md CLAUDE.md docs/plans/2026-05-09-phase-n5b-perf-baseline.md +# Memory file is outside the repo, skip git for it +git commit -m "$(cat <<'EOF' +phase(N.5b): SHIP — terrain on modern rendering path + +TerrainModernRenderer replaces TerrainChunkRenderer + TerrainRenderer. +Single global VBO/EBO + slot allocator + glMultiDrawElementsIndirect ++ bindless atlas handles. ~6-8 GL calls/frame for terrain regardless +of scene size. + +Path C: WB renderer pattern + acdream's LandblockMesh.Build (retail's +FSplitNESW formula preserved per #51). Path A killed by 49.98% +measured divergence vs retail; Path B (fork-patch WB) rejected for +maintenance burden. + +Perf at radius=5 (Holtburg): . +See docs/plans/2026-05-09-phase-n5b-perf-baseline.md. + +Visual verification: confirmed at 4 outdoor scenes (Holtburg flat + +sloped, Foundry-area, sloped landblock). No cell-boundary wobble. + +Closes issue #51. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Self-review checklist + +After all tasks land, sanity-check: + +- [ ] Build green: `dotnet build` +- [ ] All N.5 + N.5b tests green: `dotnet test --filter "FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~TerrainBlending|FullyQualifiedName~LandblockMesh|FullyQualifiedName~SplitFormulaDivergence"` +- [ ] Visual verification: all four scenes pass all six checks +- [ ] Issue #51 closed in `docs/ISSUES.md` +- [ ] Roadmap shows N.5b in "Shipped" +- [ ] Memory file written +- [ ] Perf baseline doc has real before/after numbers (not placeholders) +- [ ] CPU dispatcher reduction ≥10% at radius=5 (acceptance criterion 5)