diff --git a/docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md b/docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md new file mode 100644 index 0000000..275b0cf --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md @@ -0,0 +1,2455 @@ +# Phase A.5 — Two-tier Streaming + Horizon LOD — 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:** Deliver Phase A.5 — extend acdream's streaming radius from 5 (~1 km) to a tiered N₁=4 / N₂=12 layout (~2.3 km horizon) sustaining 240 FPS at standstill on a 240 Hz / 1440p monitor. + +**Architecture:** Two-tier streaming (near = full detail, far = terrain only) + tightening the existing per-LB entity dispatcher walk + off-thread mesh build (single worker) + fog blend at the near boundary + three visual quality wins (terrain mipmaps + anisotropic, A2C with MSAA on foliage, depth-write audit). + +**Tech Stack:** C# .NET 10, Silk.NET (OpenGL 4.3+), bindless textures (`GL_ARB_bindless_texture`), `glMultiDrawElementsIndirect`, xUnit for tests. WorldBuilder is the rendering foundation; we extend WB's `ObjectMeshManager` + acdream's `TerrainModernRenderer`. + +**Spec:** [`docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`](../specs/2026-05-09-phase-a5-two-tier-streaming-design.md) + +--- + +## Conventions + +- **Working dir:** `C:\Users\erikn\source\repos\acdream\.claude\worktrees\hopeful-darwin-ae8b87` (this worktree). +- **Branch:** `claude/hopeful-darwin-ae8b87`. +- **Build:** `dotnet build` from worktree root. +- **Test:** `dotnet test --no-build` (full suite); filter via `--filter "FullyQualifiedName~"` for targeted runs. +- **Commits:** prefix `phase(A.5):` or `feat(A.5):`/`test(A.5):`/`fix(A.5):`/`docs(A.5):` per task type. End with `Co-Authored-By: Claude Opus 4.7 (1M context) ` per the project convention. +- **Test framework:** xUnit + FluentAssertions. Existing tests use `[Fact]` + `Assert.*` style — follow that. + +--- + +## Task 1: Add `LandblockStreamTier` and `LandblockStreamJobKind` enums + +**Files:** +- Create: `src/AcDream.App/Streaming/LandblockStreamTier.cs` + +- [ ] **Step 1: Write the file** + +```csharp +namespace AcDream.App.Streaming; + +/// +/// Streaming-tier classification for a landblock. means +/// terrain mesh only; means terrain + scenery + EnvCells + +/// entity registration with the WB dispatcher. Per Phase A.5 spec §3. +/// +public enum LandblockStreamTier +{ + Far, + Near, +} + +/// +/// What work the streaming worker should perform for a given job. Distinct +/// from because +/// reads only the entity layer (terrain mesh already loaded), while +/// reads everything from scratch. Per Phase A.5 spec §4.3. +/// +public enum LandblockStreamJobKind +{ + /// Read LandBlock heightmap, build mesh, no entity layer. + LoadFar, + /// Read LandBlock + LandBlockInfo, generate scenery, build mesh, full entity layer. + LoadNear, + /// Read LandBlockInfo + scenery only — terrain already loaded for this LB. + PromoteToNear, +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build` +Expected: `Build succeeded.` 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/Streaming/LandblockStreamTier.cs +git commit -m "feat(A.5 T1): LandblockStreamTier + LandblockStreamJobKind enums" +``` + +--- + +## Task 2: Add `TwoTierDiff` record + extend `LandblockStreamJob.Load` with kind + +**Files:** +- Create: `src/AcDream.App/Streaming/TwoTierDiff.cs` +- Modify: `src/AcDream.App/Streaming/LandblockStreamJob.cs` +- Modify: `src/AcDream.App/Streaming/LandblockStreamer.cs` + +- [ ] **Step 1: Write `TwoTierDiff.cs`** + +```csharp +using System.Collections.Generic; + +namespace AcDream.App.Streaming; + +/// +/// Output of for the two-tier model. +/// Five disjoint lists describe what changed since the previous Tick. Per +/// Phase A.5 spec §4.2. +/// +public readonly record struct TwoTierDiff( + IReadOnlyList ToLoadFar, // entered far window from null (terrain only) + IReadOnlyList ToLoadNear, // entered near window from null (terrain + entities — first-tick or teleport) + IReadOnlyList ToPromote, // entered near window from far-resident (entities only) + IReadOnlyList ToDemote, // exited near window past hysteresis (drop entities) + IReadOnlyList ToUnload); // exited far window past hysteresis (drop terrain) +``` + +- [ ] **Step 2: Modify `LandblockStreamJob.cs`** + +Change the `Load` record from: + +```csharp +public sealed record Load(uint LandblockId) : LandblockStreamJob(LandblockId); +``` + +to: + +```csharp +public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId); +``` + +- [ ] **Step 3: Patch the call site to satisfy the compiler** + +In `LandblockStreamer.EnqueueLoad` (~line 91), change: + +```csharp +HandleJob(new LandblockStreamJob.Load(landblockId)); +``` + +to: + +```csharp +HandleJob(new LandblockStreamJob.Load(landblockId, LandblockStreamJobKind.LoadNear)); +``` + +The `LoadNear` placeholder reproduces today's "full load" semantics; Task 16 replaces this with proper routing. + +- [ ] **Step 4: Build green** + +Run: `dotnet build` +Expected: `Build succeeded.` 0 errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Streaming/TwoTierDiff.cs src/AcDream.App/Streaming/LandblockStreamJob.cs src/AcDream.App/Streaming/LandblockStreamer.cs +git commit -m "feat(A.5 T2): TwoTierDiff record + LandblockStreamJob.Load.Kind" +``` + +--- + +## Task 3: Test — `StreamingRegion` two-radius constructor + +**Files:** +- Create: `tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs` +- Modify: `src/AcDream.App/Streaming/StreamingRegion.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +using AcDream.App.Streaming; +using Xunit; + +namespace AcDream.Core.Tests.Streaming; + +public class StreamingRegionTwoTierTests +{ + [Fact] + public void Constructor_TwoRadii_ExposesNearAndFarRadii() + { + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 4, farRadius: 12); + + Assert.Equal(4, region.NearRadius); + Assert.Equal(12, region.FarRadius); + Assert.Equal(100, region.CenterX); + Assert.Equal(100, region.CenterY); + } +} +``` + +- [ ] **Step 2: Run test — verify fails** + +Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests"` +Expected: FAIL — `StreamingRegion` has no constructor taking `nearRadius`/`farRadius`. + +- [ ] **Step 3: Add the two-radius constructor** + +In `src/AcDream.App/Streaming/StreamingRegion.cs`, add (don't remove the +existing single-radius constructor yet — that gets cleaned up in Task 19): + +```csharp +public int NearRadius { get; } +public int FarRadius { get; } + +public StreamingRegion(int centerX, int centerY, int nearRadius, int farRadius) +{ + NearRadius = nearRadius; + FarRadius = farRadius; + Radius = farRadius; // outer ring drives Resident bookkeeping below + Recenter(centerX, centerY); +} +``` + +If the existing constructor is `public StreamingRegion(int cx, int cy, int radius)`, +preserve it as a thin wrapper: + +```csharp +public StreamingRegion(int cx, int cy, int radius) : this(cx, cy, radius, radius) { } +``` + +- [ ] **Step 4: Run test — verify passes** + +Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs src/AcDream.App/Streaming/StreamingRegion.cs +git commit -m "test(A.5 T3): StreamingRegion two-radius constructor" +``` + +--- + +## Task 4: Test + implement `ComputeFirstTickDiff` + +**Files:** +- Modify: `tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs` +- Modify: `src/AcDream.App/Streaming/StreamingRegion.cs` + +- [ ] **Step 1: Add the failing test** + +Append to `StreamingRegionTwoTierTests.cs`: + +```csharp +[Fact] +public void RecenterTo_FirstTick_SplitsLoadIntoNearAndFar() +{ + // near=1, far=3 → near window is 3×3=9, far window is 7×7-3×3=40 LBs. + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3); + var diff = region.ComputeFirstTickDiff(); + + Assert.Equal(9, diff.ToLoadNear.Count); + Assert.Equal(40, diff.ToLoadFar.Count); + Assert.Empty(diff.ToPromote); + Assert.Empty(diff.ToDemote); + Assert.Empty(diff.ToUnload); +} +``` + +- [ ] **Step 2: Run test — verify fails** + +Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests.RecenterTo_FirstTick"` +Expected: FAIL or compile error — `ComputeFirstTickDiff` doesn't exist. + +- [ ] **Step 3: Implement `ComputeFirstTickDiff`** + +In `StreamingRegion.cs`: + +```csharp +/// +/// First-tick bootstrap: emit ToLoadNear for every LB in the inner ring, +/// ToLoadFar for every LB in the outer ring (between near and far). Used +/// by on the first call before any +/// RecenterTo. +/// +public TwoTierDiff ComputeFirstTickDiff() +{ + var near = new List(); + var far = new List(); + for (int dx = -FarRadius; dx <= FarRadius; dx++) + { + for (int dy = -FarRadius; dy <= FarRadius; dy++) + { + int nx = CenterX + dx; + int ny = CenterY + dy; + if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue; + int absDx = System.Math.Abs(dx); + int absDy = System.Math.Abs(dy); + var id = EncodeLandblockId(nx, ny); + if (absDx <= NearRadius && absDy <= NearRadius) + near.Add(id); + else + far.Add(id); + } + } + return new TwoTierDiff( + ToLoadFar: far, + ToLoadNear: near, + ToPromote: System.Array.Empty(), + ToDemote: System.Array.Empty(), + ToUnload: System.Array.Empty()); +} +``` + +Uses Chebyshev (chess-king) distance — same convention as the existing `Recenter`. + +- [ ] **Step 4: Run test — verify passes** + +Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests.RecenterTo_FirstTick"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs src/AcDream.App/Streaming/StreamingRegion.cs +git commit -m "feat(A.5 T4): StreamingRegion ComputeFirstTickDiff" +``` + +--- + +## Task 5: Test + implement `RecenterTo` two-tier overload (covers null→Far, Far→Near, Near→Far, Far→null) + +**Files:** +- Modify: `tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs` +- Modify: `src/AcDream.App/Streaming/StreamingRegion.cs` + +- [ ] **Step 1: Add the failing test (null→Far transition)** + +```csharp +[Fact] +public void RecenterTo_PlayerWalks_NullToFar_AppearsInToLoadFar() +{ + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // Walk one LB east — center moves from (100,100) to (101,100). + // The east column at lbX=104 (relative dx=+3 from new center) enters + // the far window from null. + var diff = region.RecenterTo(newCx: 101, newCy: 100); + + foreach (var y in new[] { 97, 98, 99, 100, 101, 102, 103 }) + { + var id = StreamingRegion.EncodeLandblockIdForTest(104, y); + Assert.Contains(id, diff.ToLoadFar); + } + Assert.Empty(diff.ToLoadNear); +} +``` + +- [ ] **Step 2: Run test — verify fails** + +Expected: FAIL — `MarkResidentFromBootstrap` / `EncodeLandblockIdForTest` don't exist + `RecenterTo` doesn't yet produce a `TwoTierDiff`. + +- [ ] **Step 3: Implement two-tier `RecenterTo` + helpers** + +In `StreamingRegion.cs`: + +```csharp +internal enum TierResidence { None, Far, Near } +private readonly Dictionary _tierResidence = new(); + +public void MarkResidentFromBootstrap() +{ + for (int dx = -FarRadius; dx <= FarRadius; dx++) + { + for (int dy = -FarRadius; dy <= FarRadius; dy++) + { + int nx = CenterX + dx; + int ny = CenterY + dy; + if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue; + int absDx = System.Math.Abs(dx); + int absDy = System.Math.Abs(dy); + var id = EncodeLandblockId(nx, ny); + _tierResidence[id] = (absDx <= NearRadius && absDy <= NearRadius) + ? TierResidence.Near + : TierResidence.Far; + } + } +} + +internal static uint EncodeLandblockIdForTest(int lbX, int lbY) + => EncodeLandblockId(lbX, lbY); + +/// +/// Two-tier overload of RecenterTo. Computes the 5-list diff per Phase A.5 spec §4.2. +/// Hysteresis: NearRadius+2 for near→far demote; FarRadius+2 for far→null unload. +/// +public TwoTierDiff RecenterTo(int newCx, int newCy) +{ + int nearUnloadThreshold = NearRadius + 2; + int farUnloadThreshold = FarRadius + 2; + + var toLoadFar = new List(); + var toLoadNear = new List(); + var toPromote = new List(); + var toDemote = new List(); + var toUnload = new List(); + + // Pass 1: walk new far window — emit ToLoadFar / ToLoadNear / ToPromote. + var newCenterIds = new HashSet(); + for (int dx = -FarRadius; dx <= FarRadius; dx++) + { + for (int dy = -FarRadius; dy <= FarRadius; dy++) + { + int nx = newCx + dx; + int ny = newCy + dy; + if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue; + int absDx = System.Math.Abs(dx); + int absDy = System.Math.Abs(dy); + bool inNear = absDx <= NearRadius && absDy <= NearRadius; + var id = EncodeLandblockId(nx, ny); + newCenterIds.Add(id); + + if (!_tierResidence.TryGetValue(id, out var current)) + { + if (inNear) toLoadNear.Add(id); + else toLoadFar.Add(id); + _tierResidence[id] = inNear ? TierResidence.Near : TierResidence.Far; + } + else if (current == TierResidence.Far && inNear) + { + toPromote.Add(id); + _tierResidence[id] = TierResidence.Near; + } + } + } + + // Pass 2: handle previously-resident LBs — demote / unload by distance. + foreach (var kvp in _tierResidence.ToArray()) + { + var id = kvp.Key; + var current = kvp.Value; + int lbX = (int)((id >> 24) & 0xFFu); + int lbY = (int)((id >> 16) & 0xFFu); + int absDx = System.Math.Abs(lbX - newCx); + int absDy = System.Math.Abs(lbY - newCy); + int distance = System.Math.Max(absDx, absDy); + + if (newCenterIds.Contains(id)) + { + // Possible Near→Far demote even though id is in window: was Near, + // now outside near radius (but still within hysteresis window). + if (current == TierResidence.Near && (absDx > NearRadius || absDy > NearRadius)) + { + if (distance > nearUnloadThreshold) + { + toDemote.Add(id); + _tierResidence[id] = TierResidence.Far; + } + } + continue; + } + + // Outside new window — check unload thresholds. + if (current == TierResidence.Near) + { + if (distance > nearUnloadThreshold) + { + toDemote.Add(id); + _tierResidence[id] = TierResidence.Far; + if (distance > farUnloadThreshold) + { + toUnload.Add(id); + _tierResidence.Remove(id); + } + } + } + else if (current == TierResidence.Far) + { + if (distance > farUnloadThreshold) + { + toUnload.Add(id); + _tierResidence.Remove(id); + } + } + } + + CenterX = newCx; + CenterY = newCy; + + return new TwoTierDiff(toLoadFar, toLoadNear, toPromote, toDemote, toUnload); +} +``` + +If `CenterX` / `CenterY` are currently `{ get; }` (init-only), change to +`{ get; private set; }`. + +- [ ] **Step 4: Run test — verify passes** + +Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests.RecenterTo_PlayerWalks_NullToFar"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs src/AcDream.App/Streaming/StreamingRegion.cs +git commit -m "feat(A.5 T5): StreamingRegion two-tier RecenterTo + TierResidence tracking" +``` + +--- + +## Task 6: Tests for Far→Near, null→Near (teleport), Near→Far hysteresis, Far→null hysteresis, oscillation + +**Files:** +- Modify: `tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs` + +- [ ] **Step 1: Add Far→Near (Promote) test** + +```csharp +[Fact] +public void RecenterTo_PlayerWalks_FarToNear_AppearsInToPromote() +{ + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // Walk 2 east — center (102, 100). LB (102, 100) was at distance 2 (Far) + // from (100,100); now at distance 0 → Near. That's a Promote. + var diff = region.RecenterTo(newCx: 102, newCy: 100); + + var promotedId = StreamingRegion.EncodeLandblockIdForTest(102, 100); + Assert.Contains(promotedId, diff.ToPromote); + Assert.DoesNotContain(promotedId, diff.ToLoadNear); + Assert.DoesNotContain(promotedId, diff.ToLoadFar); +} +``` + +- [ ] **Step 2: Add null→Near (teleport) test** + +```csharp +[Fact] +public void RecenterTo_PlayerTeleports_NullToNear_AppearsInToLoadNear() +{ + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // Teleport to (200, 200) — entirely new region. + var diff = region.RecenterTo(newCx: 200, newCy: 200); + + Assert.Equal(9, diff.ToLoadNear.Count); + Assert.Equal(40, diff.ToLoadFar.Count); + Assert.Empty(diff.ToPromote); +} +``` + +- [ ] **Step 3: Add Near→Far hysteresis test** + +```csharp +[Fact] +public void RecenterTo_NearToFar_DemoteOnlyAfterHysteresis() +{ + // near=2, far=4 → near hysteresis threshold = 4. + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 2, farRadius: 4); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // LB (100,100) was Near. Walk 3 east → distance 3 > NearRadius=2 but ≤ 4. + // No demote yet. + var diff1 = region.RecenterTo(newCx: 103, newCy: 100); + var lb100 = StreamingRegion.EncodeLandblockIdForTest(100, 100); + Assert.DoesNotContain(lb100, diff1.ToDemote); + + // Walk 2 more east → distance 5 > 4. Demote. + var diff2 = region.RecenterTo(newCx: 105, newCy: 100); + Assert.Contains(lb100, diff2.ToDemote); +} +``` + +- [ ] **Step 4: Add Far→null hysteresis test** + +```csharp +[Fact] +public void RecenterTo_FarToNull_UnloadOnlyAfterHysteresis() +{ + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // LB (97, 100) was at distance 3 (Far). Walk 1 east → distance 4. ≤ FarRadius+2=5. + var diff1 = region.RecenterTo(newCx: 101, newCy: 100); + var lb97 = StreamingRegion.EncodeLandblockIdForTest(97, 100); + Assert.DoesNotContain(lb97, diff1.ToUnload); + + // Walk 2 more east → distance 6 > 5. Unload. + var diff2 = region.RecenterTo(newCx: 103, newCy: 100); + Assert.Contains(lb97, diff2.ToUnload); +} +``` + +- [ ] **Step 5: Add oscillation no-thrash test** + +```csharp +[Fact] +public void RecenterTo_PlayerOscillates_NoThrashWithinHysteresis() +{ + var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 2, farRadius: 4); + _ = region.ComputeFirstTickDiff(); + region.MarkResidentFromBootstrap(); + + // Bounce between (102,100) and (103,100). Distance from each to (100,100) + // is 2 and 3 — both within NearRadius+2=4 hysteresis. No demote should fire. + int totalDemotes = 0; + int totalPromotes = 0; + for (int i = 0; i < 5; i++) + { + var d1 = region.RecenterTo(103, 100); + totalDemotes += d1.ToDemote.Count; + totalPromotes += d1.ToPromote.Count; + var d2 = region.RecenterTo(102, 100); + totalDemotes += d2.ToDemote.Count; + totalPromotes += d2.ToPromote.Count; + } + + Assert.Equal(0, totalDemotes); + // Some promote on the very first crossing is expected (LBs that were Far + // becoming Near); after that, oscillation should settle. + Assert.True(totalPromotes <= 4, + $"Expected ≤4 promotes across 5 oscillations; got {totalPromotes}"); +} +``` + +- [ ] **Step 6: Run all five tests — verify pass** + +Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingRegionTwoTierTests"` +Expected: 6 passing total (the 1 from Task 3 + 5 added here). + +- [ ] **Step 7: Commit** + +```bash +git add tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs +git commit -m "test(A.5 T6): StreamingRegion transitions + hysteresis + oscillation coverage" +``` + +--- + +## Task 7: Extend `LandblockStreamResult.Loaded` with Tier + MeshData; add `Promoted` + +**Files:** +- Modify: `src/AcDream.App/Streaming/LandblockStreamJob.cs` +- Modify: `src/AcDream.App/Streaming/LandblockStreamer.cs` + +- [ ] **Step 1: Replace `LandblockStreamResult` with extended variants** + +In `LandblockStreamJob.cs`, replace the existing `LandblockStreamResult` +record block: + +```csharp +using System.Collections.Generic; +using AcDream.Core.Terrain; +using AcDream.Core.World; + +public abstract record LandblockStreamResult(uint LandblockId) +{ + /// + /// A landblock load completed. distinguishes Far + /// (terrain only) from Near (terrain + entities). + /// is built off the render thread on the streaming worker. + /// + public sealed record Loaded( + uint LandblockId, + LandblockStreamTier Tier, + LoadedLandblock Landblock, + LandblockMeshData MeshData + ) : LandblockStreamResult(LandblockId); + + /// + /// A previously-Far-resident landblock was promoted to Near. Terrain + /// mesh is already on the GPU; the result carries the entity layer + /// (stabs, buildings, scenery) to merge into the existing GpuWorldState + /// entry. + /// + public sealed record Promoted( + uint LandblockId, + IReadOnlyList Entities + ) : LandblockStreamResult(LandblockId); + + public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId); + public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId); + public sealed record WorkerCrashed(string Error) : LandblockStreamResult(0); +} +``` + +- [ ] **Step 2: Patch `LandblockStreamer.HandleJob` to compile (placeholder MeshData)** + +In `LandblockStreamer.HandleJob` (line ~167), update the `Loaded` construction: + +```csharp +// TEMPORARY: passes default! for MeshData — Task 13 wires the real mesh build. +_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded( + load.LandblockId, + LandblockStreamTier.Near, + lb, + MeshData: default! /* TODO(A.5 T13) */)); +``` + +- [ ] **Step 3: Build verify** + +Run: `dotnet build` +Expected: build succeeded. + +- [ ] **Step 4: Run all tests still pass** + +Run: `dotnet test --no-build` +Expected: previously-passing tests still pass; new tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Streaming/LandblockStreamJob.cs src/AcDream.App/Streaming/LandblockStreamer.cs +git commit -m "feat(A.5 T7): LandblockStreamResult.Loaded.Tier + MeshData; Promoted variant" +``` + +--- + +## Task 8: Add `WorldEntity.AabbMin/AabbMax` cache + dirty flag + `RefreshAabb` + `SetPosition` + +**Files:** +- Modify: `src/AcDream.Core/World/WorldEntity.cs` +- Create: `tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +using System.Numerics; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.World; + +public class WorldEntityAabbTests +{ + [Fact] + public void Aabb_DefaultRadius_PositionPlusMinus5() + { + var entity = new WorldEntity + { + Id = 1, + Position = new Vector3(10, 20, 30), + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + entity.RefreshAabb(); + + Assert.Equal(new Vector3(5, 15, 25), entity.AabbMin); + Assert.Equal(new Vector3(15, 25, 35), entity.AabbMax); + } + + [Fact] + public void Aabb_DirtyFlag_SetByMutator_ClearedByRefresh() + { + var entity = new WorldEntity + { + Id = 1, + Position = new Vector3(10, 20, 30), + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + entity.RefreshAabb(); + Assert.False(entity.AabbDirty); + + entity.SetPosition(new Vector3(100, 200, 300)); + Assert.True(entity.AabbDirty); + + entity.RefreshAabb(); + Assert.False(entity.AabbDirty); + Assert.Equal(new Vector3(95, 195, 295), entity.AabbMin); + } +} +``` + +- [ ] **Step 2: Run test — verify fails** + +Run: `dotnet test --no-build --filter "FullyQualifiedName~WorldEntityAabbTests"` +Expected: FAIL — fields/methods don't exist. + +- [ ] **Step 3: Add fields and methods to `WorldEntity`** + +Locate `WorldEntity.cs` and add: + +```csharp +// Per Phase A.5 spec §4.6 Change #2 — cache per-entity AABB so the +// dispatcher's frustum cull is a memory read, not a per-frame recompute. +public Vector3 AabbMin { get; private set; } +public Vector3 AabbMax { get; private set; } +public bool AabbDirty { get; private set; } = true; + +private const float DefaultAabbRadius = 5.0f; + +public void RefreshAabb() +{ + var p = Position; + AabbMin = new Vector3(p.X - DefaultAabbRadius, p.Y - DefaultAabbRadius, p.Z - DefaultAabbRadius); + AabbMax = new Vector3(p.X + DefaultAabbRadius, p.Y + DefaultAabbRadius, p.Z + DefaultAabbRadius); + AabbDirty = false; +} + +public void SetPosition(Vector3 pos) +{ + Position = pos; + AabbDirty = true; +} +``` + +If `Position` is currently `{ get; init; }`, change to `{ get; set; }` so +`SetPosition` can write it. Object-initializer assignments still compile. + +- [ ] **Step 4: Run test — verify passes** + +Run: `dotnet test --no-build --filter "FullyQualifiedName~WorldEntityAabbTests"` +Expected: PASS, 2 tests. + +- [ ] **Step 5: Commit** + +```bash +git add tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs src/AcDream.Core/World/WorldEntity.cs +git commit -m "feat(A.5 T8): WorldEntity AABB cache + dirty flag" +``` + +--- + +## Task 9: Swap `_surfaceCache` to `ConcurrentDictionary` for thread-safety + +**Files:** +- Modify: `src/AcDream.Core/Terrain/LandblockMesh.cs` +- Modify: the `_surfaceCache` owner (find via grep) + +- [ ] **Step 1: Locate the `_surfaceCache` owner** + +Run: `Grep "surfaceCache|SurfaceCache" --include "*.cs" src/AcDream.App` from worktree root. +Identify which class declares the cache passed to `LandblockMesh.Build`. + +- [ ] **Step 2: Widen `LandblockMesh.Build` parameter to `IDictionary`** + +In `LandblockMesh.cs`, change: + +```csharp +public static LandblockMeshData Build( + LandBlock block, + uint landblockX, + uint landblockY, + float[] heightTable, + TerrainBlendingContext ctx, + Dictionary surfaceCache) +``` + +to: + +```csharp +public static LandblockMeshData Build( + LandBlock block, + uint landblockX, + uint landblockY, + float[] heightTable, + TerrainBlendingContext ctx, + System.Collections.Generic.IDictionary surfaceCache) +``` + +The lookup pattern in Build (lines ~108-112) is: + +```csharp +if (!surfaceCache.TryGetValue(palCode, out var surf)) +{ + surf = TerrainBlending.BuildSurface(palCode, ctx); + surfaceCache[palCode] = surf; +} +``` + +This is NOT atomic under contention. Two workers may both run `BuildSurface` +for the same palCode and the last write wins. Result is deterministic +(same inputs → same SurfaceInfo) so the race is benign. We accept it. + +- [ ] **Step 3: At the cache-owner site, switch to `ConcurrentDictionary`** + +```csharp +private readonly System.Collections.Concurrent.ConcurrentDictionary _surfaceCache = new(); +``` + +Compiles unchanged because of the interface widening. + +- [ ] **Step 4: Build + all tests pass** + +Run: `dotnet build && dotnet test --no-build` +Expected: build succeeded; all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core/Terrain/LandblockMesh.cs +git commit -m "refactor(A.5 T9): _surfaceCache → ConcurrentDictionary for off-thread mesh build" +``` + +--- + +## Task 10: Add DatCollection thread-safety lock + +**Files:** +- Modify: wherever `DatCollection` is owned + accessed (likely `GameWindow.cs` and various spawn handlers). + +**Background:** Per `LandblockStreamer.cs:18-27` comments, `DatCollection` +is not thread-safe. A.5 needs the worker to call `_dats.Get` / +`_dats.Get` concurrently with the render thread's other +dat reads (entity spawn, particle effects, animation sequencer). + +**Mitigation:** Wrap `DatCollection` accesses in a `lock` so reads +serialize. Lock contention is minimal in practice. + +- [ ] **Step 1: Locate DatCollection access sites** + +Run: `Grep "_dats\.Get|DatCollection\." --include "*.cs" src/AcDream.App src/AcDream.Core` from worktree root. + +- [ ] **Step 2: Add `_datsLock` field next to the DatCollection field** + +```csharp +private readonly object _datsLock = new(); +``` + +- [ ] **Step 3: Wrap each `_dats.Get(...)` access in the lock** + +Two patterns acceptable: + +(a) Inline lock at each call site: + +```csharp +LandBlock? block; +lock (_datsLock) { block = _dats.Get(id); } +``` + +(b) Helper method: + +```csharp +private T? GetDat(uint id) where T : class +{ + lock (_datsLock) { return _dats.Get(id); } +} +``` + +Pattern (b) is cleaner but requires touching every call site. Pattern (a) +is faster to apply. Either is acceptable. + +For the streamer factory specifically (where worker thread does dat reads), +the lock MUST be held — see Task 13 wiring. + +- [ ] **Step 4: Build + all tests pass** + +Run: `dotnet build && dotnet test --no-build` +Expected: build succeeded; all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add +git commit -m "fix(A.5 T10): serialize DatCollection access via lock for off-thread streaming" +``` + +--- + +## Task 11: Activate `LandblockStreamer` worker thread + +**Files:** +- Modify: `src/AcDream.App/Streaming/LandblockStreamer.cs` + +**Background:** `WorkerLoop` exists but `Start()` is a no-op (synchronous mode). +A.5 activates the worker. + +- [ ] **Step 1: Activate the worker thread in `Start()`** + +Replace `Start()`: + +```csharp +public void Start() +{ + if (System.Threading.Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(LandblockStreamer)); + if (_worker != null) return; + _worker = new Thread(WorkerLoop) + { + IsBackground = true, + Name = "acdream.streaming.worker", + }; + _worker.Start(); +} +``` + +Remove the `#pragma warning disable CS0649` around `_worker` since it's +now assigned. + +- [ ] **Step 2: Make enqueue methods non-blocking — write to inbox channel** + +Replace: + +```csharp +public void EnqueueLoad(uint landblockId) +{ + if (System.Threading.Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(LandblockStreamer)); + HandleJob(new LandblockStreamJob.Load(landblockId, LandblockStreamJobKind.LoadNear)); +} +``` + +with: + +```csharp +public void EnqueueLoad(uint landblockId, LandblockStreamJobKind kind) +{ + if (System.Threading.Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(LandblockStreamer)); + _inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind)); +} + +public void EnqueueUnload(uint landblockId) +{ + if (System.Threading.Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(LandblockStreamer)); + _inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId)); +} +``` + +- [ ] **Step 3: Update existing call sites to pass `JobKind`** + +Run: `Grep "\.EnqueueLoad\(" --include "*.cs"` from worktree root. + +For each, update to pass an appropriate `LandblockStreamJobKind`. Tests +that don't care can pass `LandblockStreamJobKind.LoadNear` (today's behavior). + +- [ ] **Step 4: Build + run streaming tests** + +Run: `dotnet build && dotnet test --no-build --filter "FullyQualifiedName~Streaming"` +Expected: build succeeded; all streaming tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Streaming/LandblockStreamer.cs +git commit -m "feat(A.5 T11): activate LandblockStreamer worker thread; EnqueueLoad takes JobKind" +``` + +--- + +## Task 12: Inject mesh-build dependency into `LandblockStreamer` + +**Files:** +- Modify: `src/AcDream.App/Streaming/LandblockStreamer.cs` +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the construction site) + +- [ ] **Step 1: Add `_buildMeshOrNull` constructor param + field** + +In `LandblockStreamer.cs`: + +```csharp +private readonly Func _buildMeshOrNull; + +public LandblockStreamer( + Func loadLandblock, + Func buildMeshOrNull) +{ + _loadLandblock = loadLandblock; + _buildMeshOrNull = buildMeshOrNull; + _inbox = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + _outbox = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); +} +``` + +- [ ] **Step 2: Update `HandleJob` to build mesh + post `Loaded` with Tier + MeshData** + +```csharp +case LandblockStreamJob.Load load: + try + { + var lb = _loadLandblock(load.LandblockId); + if (lb is null) + { + _outbox.Writer.TryWrite(new LandblockStreamResult.Failed( + load.LandblockId, "LandblockLoader.Load returned null")); + break; + } + var mesh = _buildMeshOrNull(load.LandblockId); + if (mesh is null) + { + _outbox.Writer.TryWrite(new LandblockStreamResult.Failed( + load.LandblockId, "LandblockMesh.Build returned null")); + break; + } + var tier = load.Kind == LandblockStreamJobKind.LoadFar + ? LandblockStreamTier.Far : LandblockStreamTier.Near; + _outbox.Writer.TryWrite(new LandblockStreamResult.Loaded( + load.LandblockId, tier, lb, mesh)); + } + catch (Exception ex) + { + _outbox.Writer.TryWrite(new LandblockStreamResult.Failed( + load.LandblockId, ex.ToString())); + } + break; +``` + +The `LoadFar` fast path (skip `LandBlockInfo` read) is OK to defer — the +worker still reads everything for now; the render-thread routing in Task 14 +filters far-tier entities out anyway. Performance optimization for fast-path +goes in a follow-up task or N.6. + +- [ ] **Step 3: Wire mesh-build factory at `LandblockStreamer` construction in `GameWindow`** + +In `GameWindow.cs`, locate the `_streamer = new LandblockStreamer(...)` line. +Update: + +```csharp +_streamer = new LandblockStreamer( + loadLandblock: id => + { + lock (_datsLock) { return LandblockLoader.Load(_dats, id); } + }, + buildMeshOrNull: id => + { + LandBlock? block; + lock (_datsLock) { block = _dats.Get(id); } + if (block is null) return null; + uint lbX = (id >> 24) & 0xFFu; + uint lbY = (id >> 16) & 0xFFu; + // _heightTable, _terrainCtx, _surfaceCache populated at startup + return LandblockMesh.Build(block, lbX, lbY, _heightTable, _terrainCtx, _surfaceCache); + }); +``` + +`_surfaceCache` is now `ConcurrentDictionary` (Task 9). + +After construction, call `_streamer.Start()` (Task 11 activated this). + +- [ ] **Step 4: Build + run streaming tests** + +Run: `dotnet build && dotnet test --no-build --filter "FullyQualifiedName~Streaming"` +Expected: build succeeded; tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Streaming/LandblockStreamer.cs src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(A.5 T12): inject mesh-build dependency into LandblockStreamer" +``` + +--- + +## Task 13: `StreamingController` two-tier `Tick` + `applyTerrain` accepts MeshData + +**Files:** +- Modify: `src/AcDream.App/Streaming/StreamingController.cs` +- Create: `tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs` +- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs` + +- [ ] **Step 1: Stub the new GpuWorldState methods** + +In `GpuWorldState.cs`, add stubs (Task 14 implements): + +```csharp +public void RemoveEntitiesFromLandblock(uint landblockId) +{ + throw new System.NotImplementedException("A.5 T14"); +} + +public void AddEntitiesToExistingLandblock(uint landblockId, System.Collections.Generic.IReadOnlyList entities) +{ + throw new System.NotImplementedException("A.5 T14"); +} +``` + +- [ ] **Step 2: Rewrite `StreamingController` for two-tier** + +Replace the existing constructor and `Tick`: + +```csharp +private readonly Action _enqueueLoad; +private readonly Action _enqueueUnload; +private readonly Func> _drainCompletions; +private readonly Action _applyTerrain; +private readonly Action? _removeTerrain; +private readonly GpuWorldState _state; +private StreamingRegion? _region; + +public int NearRadius { get; set; } +public int FarRadius { get; set; } +public int MaxCompletionsPerFrame { get; set; } = 4; + +public StreamingController( + Action enqueueLoad, + Action enqueueUnload, + Func> drainCompletions, + Action applyTerrain, + GpuWorldState state, + int nearRadius, + int farRadius, + Action? removeTerrain = null) +{ + _enqueueLoad = enqueueLoad; + _enqueueUnload = enqueueUnload; + _drainCompletions = drainCompletions; + _applyTerrain = applyTerrain; + _removeTerrain = removeTerrain; + _state = state; + NearRadius = nearRadius; + FarRadius = farRadius; +} + +public void Tick(int observerCx, int observerCy) +{ + if (_region is null) + { + _region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius); + var bootstrap = _region.ComputeFirstTickDiff(); + foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar); + foreach (var id in bootstrap.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear); + _region.MarkResidentFromBootstrap(); + } + else if (_region.CenterX != observerCx || _region.CenterY != observerCy) + { + var diff = _region.RecenterTo(observerCx, observerCy); + foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar); + foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear); + foreach (var id in diff.ToPromote) _enqueueLoad(id, LandblockStreamJobKind.PromoteToNear); + foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id); + foreach (var id in diff.ToUnload) _enqueueUnload(id); + } + + var drained = _drainCompletions(MaxCompletionsPerFrame); + foreach (var result in drained) + { + switch (result) + { + case LandblockStreamResult.Loaded loaded: + _applyTerrain(loaded.Landblock, loaded.MeshData); + _state.AddLandblock(loaded.Landblock); + break; + case LandblockStreamResult.Promoted promoted: + _state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities); + break; + case LandblockStreamResult.Unloaded unloaded: + _state.RemoveLandblock(unloaded.LandblockId); + _removeTerrain?.Invoke(unloaded.LandblockId); + break; + case LandblockStreamResult.Failed failed: + System.Console.WriteLine( + $"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}"); + break; + case LandblockStreamResult.WorkerCrashed crashed: + System.Console.WriteLine( + $"streaming: worker CRASHED: {crashed.Error}"); + break; + } + } +} +``` + +- [ ] **Step 3: Write the failing test (first-tick bootstrap)** + +```csharp +using System.Collections.Generic; +using AcDream.App.Streaming; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.Streaming; + +public class StreamingControllerTwoTierTests +{ + [Fact] + public void Tick_FirstCall_EnqueuesNearAndFarLoadsByTier() + { + var loads = new List<(uint Id, LandblockStreamJobKind Kind)>(); + var unloads = new List(); + var completions = new List(); + var state = new GpuWorldState(); + + var ctrl = new StreamingController( + enqueueLoad: (id, kind) => loads.Add((id, kind)), + enqueueUnload: unloads.Add, + drainCompletions: _ => completions, + applyTerrain: (_, _) => { }, + state: state, + nearRadius: 1, + farRadius: 3); + + ctrl.Tick(observerCx: 100, observerCy: 100); + + int nearCount = 0, farCount = 0; + foreach (var (_, kind) in loads) + { + if (kind == LandblockStreamJobKind.LoadNear) nearCount++; + else if (kind == LandblockStreamJobKind.LoadFar) farCount++; + } + Assert.Equal(9, nearCount); + Assert.Equal(40, farCount); + } +} +``` + +- [ ] **Step 4: Build + run new test** + +Run: `dotnet build && dotnet test --no-build --filter "FullyQualifiedName~StreamingControllerTwoTierTests"` +Expected: build succeeded; new test PASS. Existing single-radius `StreamingControllerTests` +will fail compile — fix in Task 16. + +- [ ] **Step 5: Commit** + +```bash +git add tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs src/AcDream.App/Streaming/StreamingController.cs src/AcDream.App/Streaming/GpuWorldState.cs +git commit -m "feat(A.5 T13): StreamingController two-tier Tick + first-tick bootstrap" +``` + +--- + +## Task 14: Implement `GpuWorldState.RemoveEntitiesFromLandblock` + `AddEntitiesToExistingLandblock` + +**Files:** +- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs` +- Create: `tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs` + +- [ ] **Step 1: Write the failing tests** + +```csharp +using System.Linq; +using AcDream.App.Streaming; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.Streaming; + +public class GpuWorldStateTwoTierTests +{ + [Fact] + public void RemoveEntitiesFromLandblock_KeepsLandblockButDropsEntities() + { + var state = new GpuWorldState(); + var lb = new LoadedLandblock(0xAAAA_FFFF, Heightmap: null!, + Entities: new[] + { + new WorldEntity { Id = 1, MeshRefs = System.Array.Empty() }, + new WorldEntity { Id = 2, MeshRefs = System.Array.Empty() }, + }); + state.AddLandblock(lb); + Assert.Equal(2, state.Entities.Count); + + state.RemoveEntitiesFromLandblock(0xAAAA_FFFF); + + Assert.Empty(state.Entities); + Assert.True(state.IsLoaded(0xAAAA_FFFF)); + } + + [Fact] + public void AddEntitiesToExistingLandblock_MergesIntoExistingRecord() + { + var state = new GpuWorldState(); + var lb = new LoadedLandblock(0xAAAA_FFFF, Heightmap: null!, + Entities: new[] { new WorldEntity { Id = 1, MeshRefs = System.Array.Empty() } }); + state.AddLandblock(lb); + + state.AddEntitiesToExistingLandblock(0xAAAA_FFFF, new[] + { + new WorldEntity { Id = 2, MeshRefs = System.Array.Empty() }, + new WorldEntity { Id = 3, MeshRefs = System.Array.Empty() }, + }); + + Assert.Equal(3, state.Entities.Count); + } +} +``` + +- [ ] **Step 2: Run tests — verify fail** + +Run: `dotnet test --no-build --filter "FullyQualifiedName~GpuWorldStateTwoTierTests"` +Expected: FAIL with `NotImplementedException`. + +- [ ] **Step 3: Implement the methods** + +Replace the stubs: + +```csharp +public void RemoveEntitiesFromLandblock(uint landblockId) +{ + if (!_loaded.TryGetValue(landblockId, out var lb)) return; + if (_wbSpawnAdapter is not null) + _wbSpawnAdapter.OnLandblockUnloaded(landblockId); + _loaded[landblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty()); + _pendingByLandblock.Remove(landblockId); + RebuildFlatView(); +} + +public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList entities) +{ + if (!_loaded.TryGetValue(landblockId, out var lb)) + { + // Park as pending — same pattern as AppendLiveEntity for not-yet-loaded LBs. + if (!_pendingByLandblock.TryGetValue(landblockId, out var bucket)) + { + bucket = new List(); + _pendingByLandblock[landblockId] = bucket; + } + bucket.AddRange(entities); + return; + } + var merged = new List(lb.Entities.Count + entities.Count); + merged.AddRange(lb.Entities); + merged.AddRange(entities); + _loaded[landblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged); + if (_wbSpawnAdapter is not null) + _wbSpawnAdapter.OnLandblockLoaded(_loaded[landblockId]); + RebuildFlatView(); +} +``` + +- [ ] **Step 4: Run tests — verify pass** + +Run: `dotnet test --no-build --filter "FullyQualifiedName~GpuWorldStateTwoTierTests"` +Expected: PASS, 2 tests. + +- [ ] **Step 5: Commit** + +```bash +git add tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs src/AcDream.App/Streaming/GpuWorldState.cs +git commit -m "feat(A.5 T14): GpuWorldState RemoveEntitiesFromLandblock + AddEntitiesToExisting" +``` + +--- + +## Task 15: Add `TerrainModernRenderer.AddLandblockWithMesh` (prebuilt mesh entry point) + +**Files:** +- Modify: `src/AcDream.App/Rendering/TerrainModernRenderer.cs` + +- [ ] **Step 1: Refactor existing `AddLandblock` to delegate to `AddLandblockInternal`** + +Today's `AddLandblock(LoadedLandblock lb)` builds the mesh and adds it. +Refactor: + +```csharp +public void AddLandblock(LoadedLandblock lb) +{ + // Legacy synchronous path — fallback for callers not yet migrated. + var meshData = LandblockMesh.Build( + lb.Heightmap, /* lbX, lbY from id */, _heightTable, _terrainCtx, _surfaceCache); + AddLandblockInternal(lb, meshData); +} + +public void AddLandblockWithMesh(LoadedLandblock lb, LandblockMeshData meshData) +{ + AddLandblockInternal(lb, meshData); +} + +private void AddLandblockInternal(LoadedLandblock lb, LandblockMeshData meshData) +{ + // ... existing AddLandblock body, but using the passed meshData instead + // of building it inline. +} +``` + +If `AddLandblock` doesn't build mesh inline today (e.g., if mesh is built +elsewhere and stored on `LoadedLandblock`), the refactor is simpler: +just add `AddLandblockWithMesh(lb, meshData)` as a new entry point that +takes the mesh externally. + +- [ ] **Step 2: Build verify** + +Run: `dotnet build` +Expected: build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/Rendering/TerrainModernRenderer.cs +git commit -m "refactor(A.5 T15): TerrainModernRenderer.AddLandblockWithMesh prebuilt-mesh entry" +``` + +--- + +## Task 16: Update existing single-radius `StreamingController` tests + wire two-tier into `GameWindow` + +**Files:** +- Modify: `tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs` +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` + +- [ ] **Step 1: Run existing tests to identify failures** + +Run: `dotnet test --no-build --filter "FullyQualifiedName~StreamingControllerTests"` +Expected: compile errors / failures pointing at the old constructor signature. + +- [ ] **Step 2: Update each existing test** + +Replace `radius: N` with `nearRadius: N, farRadius: N`. Replace +`enqueueLoad: id => ...` with `enqueueLoad: (id, _) => ...` (ignore tier +in tests that don't care). Replace `applyTerrain: lb => ...` with +`applyTerrain: (lb, _) => ...`. + +For tests asserting on the original `RegionDiff`-shaped behavior, port +to the `TwoTierDiff` shape. Asserts on `ToLoad` move to `ToLoadNear` +when `nearRadius == farRadius` (single-tier behavior). + +- [ ] **Step 3: Wire two-tier into `GameWindow.cs`** + +Locate `StreamingController` construction. Replace with: + +```csharp +int nearRadius = ParseEnvInt("ACDREAM_NEAR_RADIUS", defaultValue: 4); +int farRadius = ParseEnvInt("ACDREAM_FAR_RADIUS", defaultValue: 12); + +// Backward-compat: if ACDREAM_STREAM_RADIUS is set, treat it as nearRadius +// and infer farRadius = max(streamRadius, default farRadius). +int streamRadius = ParseEnvInt("ACDREAM_STREAM_RADIUS", defaultValue: -1); +if (streamRadius > 0) +{ + nearRadius = streamRadius; + farRadius = System.Math.Max(streamRadius, farRadius); +} + +_streamingController = new StreamingController( + enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind), + enqueueUnload: id => _streamer.EnqueueUnload(id), + drainCompletions: max => _streamer.DrainCompletions(max), + applyTerrain: (lb, mesh) => _terrainModernRenderer.AddLandblockWithMesh(lb, mesh), + state: _gpuWorldState, + nearRadius: nearRadius, + farRadius: farRadius, + removeTerrain: id => _terrainModernRenderer.RemoveLandblock(id)); +``` + +If `ParseEnvInt` doesn't exist, locate the existing pattern for env-var int +parsing and reuse, or add a small helper. + +- [ ] **Step 4: Build + all tests pass** + +Run: `dotnet build && dotnet test --no-build` +Expected: build succeeded; all tests pass. + +- [ ] **Step 5: Visual gate — launch and verify no regressions** + +Build the App project; the user launches the client (per CLAUDE.md +launch flow) and verifies: +- World renders at default radii (N₁=4, N₂=12). +- No crashes during streaming. +- Player movement works. + +If anything regresses, halt and debug. + +- [ ] **Step 6: Commit** + +```bash +git add tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(A.5 T16): wire two-tier streaming into GameWindow + port existing tests" +``` + +--- + +## Task 17: Test + implement entity bucketing Change #1 — animated-entity walk fix + +**Files:** +- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` +- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs` +- Create: `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs` + +- [ ] **Step 1: Extract pure-CPU `WalkEntities` helper** + +In `WbDrawDispatcher.cs`, extract a testable helper: + +```csharp +internal struct WalkResult +{ + public int EntitiesWalked; + public List<(WorldEntity Entity, MeshRef MeshRef)> ToDraw; +} + +internal static WalkResult WalkEntities( + IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> landblockEntries, + FrustumPlanes? frustum, + uint? neverCullLandblockId, + HashSet? visibleCellIds, + HashSet? animatedEntityIds) +{ + var result = new WalkResult { ToDraw = new() }; + foreach (var entry in landblockEntries) + { + bool landblockVisible = frustum is null + || entry.LandblockId == neverCullLandblockId + || FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax); + + if (!landblockVisible) + { + // A.5 T17 Change #1: walk only animated entities, not all entities. + if (animatedEntityIds is null || animatedEntityIds.Count == 0) continue; + if (entry.AnimatedById is null) continue; + foreach (var animatedId in animatedEntityIds) + { + if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue; + if (entity.MeshRefs.Count == 0) continue; + if (entity.ParentCellId.HasValue && visibleCellIds is not null + && !visibleCellIds.Contains(entity.ParentCellId.Value)) continue; + result.EntitiesWalked++; + for (int i = 0; i < entity.MeshRefs.Count; i++) + result.ToDraw.Add((entity, entity.MeshRefs[i])); + } + continue; + } + + foreach (var entity in entry.Entities) + { + result.EntitiesWalked++; + if (entity.MeshRefs.Count == 0) continue; + if (entity.ParentCellId.HasValue && visibleCellIds is not null + && !visibleCellIds.Contains(entity.ParentCellId.Value)) + continue; + // Per-entity AABB cull (uses cached AABB after Task 18 lands). + bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true; + if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId) + { + var p = entity.Position; + var aMin = new Vector3(p.X - 5f, p.Y - 5f, p.Z - 5f); + var aMax = new Vector3(p.X + 5f, p.Y + 5f, p.Z + 5f); + if (!FrustumCuller.IsAabbVisible(frustum.Value, aMin, aMax)) continue; + } + for (int i = 0; i < entity.MeshRefs.Count; i++) + result.ToDraw.Add((entity, entity.MeshRefs[i])); + } + } + return result; +} +``` + +- [ ] **Step 2: Update `WbDrawDispatcher.Draw` to use `WalkEntities`** + +Replace the inline walk in `Draw` (lines ~191-288) with a call to +`WalkEntities`, then build groups from the result. The classify+upload+ +indirect-draw phases remain unchanged. + +The signature of `Draw`'s `landblockEntries` parameter changes to include +`AnimatedById`. Adjust the call site in `GameWindow.cs` accordingly. + +- [ ] **Step 3: Update `GpuWorldState.LandblockEntries` to yield `AnimatedById`** + +In `GpuWorldState.cs`, modify `LandblockEntries` to compute and yield +`AnimatedById`: + +```csharp +public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> LandblockEntries +{ + get + { + foreach (var kvp in _loaded) + { + // Build AnimatedById on the fly. Cheap (~132 entities/LB max). + // A.5 follow-up could cache this per-AddLandblock if profiling shows hot. + var byId = new Dictionary(kvp.Value.Entities.Count); + foreach (var e in kvp.Value.Entities) + byId[e.Id] = e; + + if (_aabbs.TryGetValue(kvp.Key, out var aabb)) + yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId); + else + yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId); + } + } +} +``` + +- [ ] **Step 4: Write the test** + +```csharp +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.Rendering.Wb; + +public class WbDrawDispatcherBucketingTests +{ + [Fact] + public void WalkEntities_InvisibleLb_AnimatedSet_WalksOnlyAnimatedEntities() + { + var entities = new List(); + for (int i = 0; i < 1000; i++) + entities.Add(new WorldEntity { Id = (uint)i, MeshRefs = System.Array.Empty() }); + + var animatedById = new Dictionary { [42] = entities[42] }; + var animatedSet = new HashSet { 42 }; + + // Construct an "always-fail" frustum: 6 planes pointing inward at the origin + // with the LB AABB far away from the origin → IsAabbVisible returns false. + var frustum = MakeAllFailFrustum(); + var entries = new[] + { + (LandblockId: 0xAAAA_FFFFu, + AabbMin: new Vector3(10000, 10000, 10000), + AabbMax: new Vector3(20000, 20000, 20000), + Entities: (IReadOnlyList)entities, + AnimatedById: (IReadOnlyDictionary?)animatedById), + }; + + var result = WbDrawDispatcher.WalkEntities( + entries, frustum, neverCullLandblockId: null, + visibleCellIds: null, animatedEntityIds: animatedSet); + + Assert.Equal(1, result.EntitiesWalked); + } + + private static FrustumPlanes MakeAllFailFrustum() + { + // Six planes at origin pointing inward — entities at (10000,...) fail all of them. + return new FrustumPlanes( + Left: new Vector4(1, 0, 0, 0), + Right: new Vector4(-1, 0, 0, 0), + Bottom: new Vector4(0, 1, 0, 0), + Top: new Vector4(0, -1, 0, 0), + Near: new Vector4(0, 0, 1, 0), + Far: new Vector4(0, 0, -1, 0)); + } +} +``` + +If `FrustumPlanes` constructor signature differs, adapt the helper. + +- [ ] **Step 5: Build + run test** + +Run: `dotnet build && dotnet test --no-build --filter "FullyQualifiedName~WbDrawDispatcherBucketingTests"` +Expected: build succeeded; test PASS. + +- [ ] **Step 6: Commit** + +```bash +git add tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs src/AcDream.App/Streaming/GpuWorldState.cs +git commit -m "feat(A.5 T17): WbDrawDispatcher Change #1 — animated-entity walk fix + WalkEntities extraction" +``` + +--- + +## Task 18: Use cached AABB in `WbDrawDispatcher.WalkEntities` + populate at register time + +**Files:** +- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` +- Modify: `src/AcDream.Core/World/LandblockLoader.cs` +- Modify: `src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs` + +- [ ] **Step 1: Populate AABB at `LandblockLoader.BuildEntitiesFromInfo`** + +In `LandblockLoader.cs`, modify the entity construction inside both `foreach` +loops to call `RefreshAabb()`: + +```csharp +foreach (var stab in info.Objects) +{ + if (!IsSupported(stab.Id)) continue; + var entity = new WorldEntity + { + Id = nextId++, + SourceGfxObjOrSetupId = stab.Id, + Position = stab.Frame.Origin, + Rotation = stab.Frame.Orientation, + MeshRefs = Array.Empty(), + }; + entity.RefreshAabb(); + result.Add(entity); +} + +// Same pattern for the buildings loop. +``` + +- [ ] **Step 2: Populate AABB at `EntitySpawnAdapter.OnCreate`** + +In `EntitySpawnAdapter.cs`, find `OnCreate(WorldEntity entity)` and add +`entity.RefreshAabb();` after the entity's fields are populated (before +the per-instance state setup). + +- [ ] **Step 3: Update dynamic-entity position-change paths** + +Run: `Grep -n "\.Position\s*=" --include "*.cs" src/AcDream.App src/AcDream.Core` from worktree root. + +For each non-init-context assignment (i.e., not inside an object-initializer +`new WorldEntity { Position = ... }`), replace with `entity.SetPosition(newPos)`. +Common sites: live position update handler, animation tick, movement controller. + +- [ ] **Step 4: Use cached AABB in `WalkEntities`** + +In `WbDrawDispatcher.WalkEntities`, replace the per-frame AABB recompute: + +```csharp +// OLD: +var p = entity.Position; +var aMin = new Vector3(p.X - 5f, p.Y - 5f, p.Z - 5f); +var aMax = new Vector3(p.X + 5f, p.Y + 5f, p.Z + 5f); +if (!FrustumCuller.IsAabbVisible(frustum.Value, aMin, aMax)) continue; + +// NEW: +if (entity.AabbDirty) entity.RefreshAabb(); +if (!FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax)) continue; +``` + +- [ ] **Step 5: Build + all tests pass** + +Run: `dotnet build && dotnet test --no-build` +Expected: build succeeded; all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs src/AcDream.Core/World/LandblockLoader.cs src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs +git commit -m "feat(A.5 T18): use cached WorldEntity AABB in dispatcher; populate at register" +``` + +--- + +## Task 19: Mipmaps + 16x anisotropic on `TerrainAtlas` + +**Files:** +- Modify: `src/AcDream.App/Rendering/TerrainAtlas.cs` + +- [ ] **Step 1: Generate mipmaps after atlas upload + set sampler params** + +Locate the atlas upload code in `TerrainAtlas.cs` (the `Upload` method). +After the `glTexImage*` / `glTexSubImage*` calls, add: + +```csharp +_gl.GenerateMipmap(TextureTarget.Texture2DArray); + +_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, + (int)TextureMinFilter.LinearMipmapLinear); +_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, + (int)TextureMagFilter.Linear); + +// Anisotropic 16x via GL_EXT/ARB_texture_filter_anisotropic. +const TextureParameterName GL_TEXTURE_MAX_ANISOTROPY = (TextureParameterName)0x84FE; +_gl.TexParameter(TextureTarget.Texture2DArray, GL_TEXTURE_MAX_ANISOTROPY, 16.0f); +``` + +If `TextureMinFilter.LinearMipmapLinear` isn't in the Silk.NET enum, cast +the int value `(int)0x2703`. + +- [ ] **Step 2: Build verify** + +Run: `dotnet build` +Expected: build succeeded. + +- [ ] **Step 3: Visual gate — launch + verify** + +User launches the client. Walk to a vantage point looking at terrain at ~2km. +Before this change: distant terrain shimmers (moving sparkles). +After: smooth. + +If shimmer persists, verify the bindless atlas handles in `terrain_modern.frag` +sample with mipmaps (the shader uses `texture(...)` which respects sampler +state automatically). + +- [ ] **Step 4: Commit** + +```bash +git add src/AcDream.App/Rendering/TerrainAtlas.cs +git commit -m "feat(A.5 T19): mipmaps + 16x anisotropic on TerrainAtlas" +``` + +--- + +## Task 20: A2C with MSAA on foliage shader + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (GL context creation) +- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (enable A2C around opaque pass) +- Modify: `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` + +- [ ] **Step 1: Audit MSAA framebuffer compatibility** + +Run: `Grep "Framebuffer|RenderTarget|ClearColor|BindFramebuffer" --include "*.cs" src/AcDream.App/Rendering` from worktree root. + +Inspect each path for default-framebuffer assumptions: +- Sky pass: expected to write to default framebuffer; should work under MSAA automatically. +- Particle pass: alpha-blend billboards; MSAA-friendly. +- ImGui overlay: drawn after 3D pass via `ImGuiPanelRenderer`; should be after MSAA resolve. +- Any offscreen FBO usage: verify resolves correctly to the MSAA default framebuffer. + +If audit finds blocking issues, defer Task 20 (per spec §10 Risk #2 fallback) +and ship Tasks 19 + 21 only. Document the result. + +If audit clean, proceed. + +- [ ] **Step 2: Enable MSAA 4x on the GL context** + +In `GameWindow.cs`, find the `WindowOptions` setup. Add MSAA samples: + +```csharp +var opts = WindowOptions.Default with { Samples = 4 }; // MSAA 4x +``` + +Or set via the existing `opts.Samples = 4` field assignment if that's the +pattern. + +- [ ] **Step 3: Enable `GL_SAMPLE_ALPHA_TO_COVERAGE` around the opaque pass** + +In `WbDrawDispatcher.Draw`, around the opaque pass (line ~400): + +```csharp +if (_opaqueDrawCount > 0) +{ + _gl.Disable(EnableCap.Blend); + _gl.DepthMask(true); + _gl.Enable(EnableCap.SampleAlphaToCoverage); // A.5 T20 — A2C for ClipMap foliage + _shader.SetInt("uRenderPass", 0); + _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer); + _gl.MultiDrawElementsIndirect(...); // existing call + _gl.Disable(EnableCap.SampleAlphaToCoverage); +} +``` + +A2C is no-op on fully-opaque alpha (≥1.0), so non-foliage opaque batches +are visually unaffected. + +- [ ] **Step 4: Update `mesh_modern.frag` for A2C-friendly output** + +Find the ClipMap branch. Replace: + +```glsl +if (texColor.a < 0.5) discard; +outColor = vec4(texColor.rgb, 1.0); +``` + +with: + +```glsl +// A.5 T20 — A2C: pass alpha through so GL_SAMPLE_ALPHA_TO_COVERAGE +// derives sample mask from coverage. +if (texColor.a < 0.05) discard; +outColor = vec4(texColor.rgb, texColor.a); +``` + +- [ ] **Step 5: Build + visual gate** + +Run: `dotnet build` +Visual gate: user launches client. Foliage edges should appear smoother +(multi-sampled). Verify sky / particles / ImGui still render correctly. + +If anything broken (sky cleared wrong, particles flicker, ImGui glitches), +roll back via `git revert` and ship without A2C (Tasks 19 + 21 only). + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs src/AcDream.App/Rendering/Shaders/mesh_modern.frag +git commit -m "feat(A.5 T20): MSAA 4x + alpha-to-coverage on foliage" +``` + +--- + +## Task 21: Depth-write audit + lock-in test + +**Files:** +- Create: `tests/AcDream.Core.Tests/Rendering/Wb/WbDispatcherDepthMaskTests.cs` + +- [ ] **Step 1: Audit `WbDrawDispatcher.Draw` depth-write state** + +Read lines ~400-435 of `WbDrawDispatcher.cs`. Confirm: +- Opaque pass: `_gl.DepthMask(true)` ✓ +- Transparent pass: `_gl.DepthMask(false)` ✓ +- After transparent: `_gl.DepthMask(true)` to restore ✓ + +If any inconsistency, fix in same task. + +- [ ] **Step 2: Write the lock-in test** + +```csharp +using AcDream.App.Rendering.Wb; +using AcDream.Core.Meshing; +using Xunit; + +namespace AcDream.Core.Tests.Rendering.Wb; + +public class WbDispatcherDepthMaskTests +{ + [Theory] + [InlineData(TranslucencyKind.Opaque, true)] // opaque pass — depth write + [InlineData(TranslucencyKind.ClipMap, true)] // foliage — depth write (binary alpha) + [InlineData(TranslucencyKind.AlphaBlend, false)] // transparent — no depth write + [InlineData(TranslucencyKind.Additive, false)] + [InlineData(TranslucencyKind.InvAlpha, false)] + public void IsOpaquePartition_ImpliesDepthWriteAttribution( + TranslucencyKind kind, bool expectsDepthWrite) + { + bool isOpaque = WbDrawDispatcher.IsOpaquePublic(kind); + Assert.Equal(expectsDepthWrite, isOpaque); + } +} +``` + +- [ ] **Step 3: Run test — verify passes** + +Run: `dotnet test --no-build --filter "FullyQualifiedName~WbDispatcherDepthMaskTests"` +Expected: PASS, 5 cases. + +- [ ] **Step 4: Commit** + +```bash +git add tests/AcDream.Core.Tests/Rendering/Wb/WbDispatcherDepthMaskTests.cs +git commit -m "test(A.5 T21): lock in depth-write attribution per translucency kind" +``` + +--- + +## Task 22: Wire fog params from N₁/N₂ + env-var multipliers + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (or wherever `SceneLightingUbo` is updated per frame) + +- [ ] **Step 1: Locate `SceneLightingUbo` update site** + +Run: `Grep "FogStart|FogEnd" --include "*.cs" src/AcDream.App` from worktree root. + +- [ ] **Step 2: Compute fog params from N₁/N₂ + env-var multipliers** + +In the per-frame fog-update path: + +```csharp +const float LandblockSize = 192.0f; +float startMult = ParseEnvFloat("ACDREAM_FOG_START_MULT", 0.7f); +float endMult = ParseEnvFloat("ACDREAM_FOG_END_MULT", 0.95f); +_sceneLighting.FogStart = _streamingController.NearRadius * LandblockSize * startMult; +_sceneLighting.FogEnd = _streamingController.FarRadius * LandblockSize * endMult; +// Fog color sourced from current sky state (existing path — unchanged). +``` + +If `ParseEnvFloat` doesn't exist: + +```csharp +private static float ParseEnvFloat(string name, float defaultValue) +{ + var s = System.Environment.GetEnvironmentVariable(name); + if (s is not null && float.TryParse(s, System.Globalization.CultureInfo.InvariantCulture, out var v)) + return v; + return defaultValue; +} +``` + +- [ ] **Step 3: Build + visual gate** + +Run: `dotnet build` +Visual gate: user launches client. At default mults, distant terrain +fades into sky color between ~538m (near boundary + some fog ramp) and +~2188m (far boundary nearly fully opaque). The N₁ scenery boundary should +be visually masked. + +If fog band is too thin / too thick, iterate on env-var mults without +rebuild. + +- [ ] **Step 4: Commit** + +```bash +git add +git commit -m "feat(A.5 T22): fog params wired from N₁/N₂ + ACDREAM_FOG_*_MULT env vars" +``` + +--- + +## Task 23: Per-subsystem regression budget logging in DIAG output + +**Files:** +- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` +- Modify: `src/AcDream.App/Rendering/TerrainModernRenderer.cs` + +- [ ] **Step 1: Add budget threshold + flag in `WbDrawDispatcher.MaybeFlushDiag`** + +Replace: + +```csharp +Console.WriteLine( + $"[WB-DIAG] entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} ..."); +``` + +with: + +```csharp +const long BudgetUs = 2000; +string budgetFlag = cpuMed > BudgetUs ? " BUDGET_OVER" : ""; +Console.WriteLine( + $"[WB-DIAG]{budgetFlag} entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} ..."); +``` + +Same pattern in `TerrainModernRenderer.MaybeFlushTerrainDiag` with +`BudgetUs = 1000`. + +- [ ] **Step 2: Build verify** + +Run: `dotnet build` +Expected: build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs src/AcDream.App/Rendering/TerrainModernRenderer.cs +git commit -m "feat(A.5 T23): BUDGET_OVER flag in [WB-DIAG] + [TERRAIN-DIAG]" +``` + +--- + +## Task 24: Capture before-baseline (radius=5 single-tier today) + +**Files:** +- Create: `docs/plans/2026-05-09-phase-a5-perf-baseline.md` + +- [ ] **Step 1: Build + launch in background with single-tier override** + +```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" +$env:ACDREAM_NEAR_RADIUS = "5" +$env:ACDREAM_FAR_RADIUS = "5" # collapse to single-tier for the baseline +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "before-radius5.log" +``` + +Run as `run_in_background: true`. + +- [ ] **Step 2: User logs in `+Acdream` and stands at Holtburg dueling field 30s** + +Then close the window. + +- [ ] **Step 3: Read `[WB-DIAG]` from the log** + +```powershell +Select-String -Path before-radius5.log -Pattern "\[WB-DIAG\]" | Select-Object -Last 5 +Select-String -Path before-radius5.log -Pattern "\[TERRAIN-DIAG\]" | Select-Object -Last 5 +``` + +Capture median + p95 cpu_us for each subsystem. + +- [ ] **Step 4: Write the baseline doc** + +```markdown +# Phase A.5 — perf baseline + +## Before (radius=5 single-tier, today's behavior) + +**Captured:** at Holtburg dueling field, NearRadius=5, FarRadius=5, +30s standstill. + +| Subsystem | cpu_us median | cpu_us p95 | +|---|---|---| +| Entity dispatcher | | | +| Terrain dispatcher | | | + +Frame time: ms median. +Effective FPS: . + +This is the "before" anchor. Task 25 captures the "after" comparison. +``` + +- [ ] **Step 5: Commit** + +```bash +git add docs/plans/2026-05-09-phase-a5-perf-baseline.md +git commit -m "docs(A.5 T24): perf baseline captured (before A.5)" +``` + +--- + +## Task 25: Capture after-baseline (full A.5: N₁=4 / N₂=12) + +**Files:** +- Modify: `docs/plans/2026-05-09-phase-a5-perf-baseline.md` + +- [ ] **Step 1: Launch with default A.5 settings** + +```powershell +# Same env vars as Task 24 minus ACDREAM_NEAR_RADIUS / ACDREAM_FAR_RADIUS +# (uses defaults 4 / 12). +$env:ACDREAM_WB_DIAG = "1" +Remove-Item Env:ACDREAM_NEAR_RADIUS -ErrorAction SilentlyContinue +Remove-Item Env:ACDREAM_FAR_RADIUS -ErrorAction SilentlyContinue +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "after-default.log" +``` + +- [ ] **Step 2: Standstill 30s + walking trace 60s** + +Standstill at Holtburg dueling field, then walk to North Yanshi. + +- [ ] **Step 3: Append after numbers to baseline doc** + +```markdown +## After (Phase A.5: N₁=4, N₂=12, full bucketing + threading + visual) + +**Captured:** , full A.5. + +### Standstill (30s, Holtburg dueling field) + +| Subsystem | cpu_us median | cpu_us p95 | +|---|---|---| +| Entity dispatcher | | | +| Terrain dispatcher | | | + +Frame time: ms median, ms p99. +Effective FPS: median. + +**Acceptance criterion 2 (median ≤ 4.166ms):** PASS / FAIL. +**Acceptance criterion 6 entity (≤ 2.0ms):** PASS / FAIL. +**Acceptance criterion 6 terrain (≤ 1.0ms):** PASS / FAIL. + +### Walking trace (60s, Holtburg → North Yanshi at run speed) + +| Subsystem | cpu_us median | cpu_us p95 | +|---|---|---| +| Entity dispatcher | | | +| Terrain dispatcher | | | + +Frame time: ms median, ms p95. +Effective FPS: median. + +**Acceptance criterion 3 (median ≥ 144 FPS):** PASS / FAIL. +``` + +- [ ] **Step 4: Commit** + +```bash +git add docs/plans/2026-05-09-phase-a5-perf-baseline.md +git commit -m "docs(A.5 T25): perf baseline captured (after A.5)" +``` + +--- + +## Task 26: Visual gate — user confirms acceptance criterion 5 + +**Files:** none (procedural) + +- [ ] **Step 1: User walks Holtburg → North Yanshi at run speed** + +User launches client at default settings. Walks the standard route. Confirms: + +1. Horizon visible at ~2.3 km. ✓ / ✗ +2. Fog blend at N₁ smooths the scenery boundary (no harsh cliff). ✓ / ✗ +3. Distant terrain does not shimmer (mipmaps work). ✓ / ✗ +4. Tree edges are smooth (A2C works, if shipped). ✓ / ✗ +5. No new z-fighting / depth artifacts. ✓ / ✗ + +- [ ] **Step 2: Triage failures** + +If any criterion fails, halt. Common failures + fixes: + +| Symptom | Likely cause | Fix | +|---|---|---| +| Distant terrain shimmers | Mipmap step skipped or sampler params wrong | Re-verify Task 19; check `glGenerateMipmap` is being called and sampler uses `LinearMipmapLinear` | +| Tree edges still pixel-stepped | A2C not enabled | Verify `Enable(EnableCap.SampleAlphaToCoverage)` in opaque pass | +| Hard scenery cliff at N₁ | Fog band too thin | Lower `ACDREAM_FOG_START_MULT` (0.5), raise `ACDREAM_FOG_END_MULT` (1.0) | +| Far horizon too washed out | Fog band too thick | Raise `ACDREAM_FOG_START_MULT`, lower `ACDREAM_FOG_END_MULT` | +| FPS dips below 144 walking | Streaming hitch | Check `[WB-DIAG]` BUDGET_OVER flag during walk; investigate hot path | + +If Bucketing Change #3 (sub-LB cell cull) is needed because Tasks 17+18 +didn't hit the 2.0ms entity dispatcher budget, add Task 18.5 implementing +4×4 sub-LB cell cull per spec §4.6 Change #3. + +- [ ] **Step 3: No commit (procedural)** + +Visual gate result documented in Task 28 SHIP commit message. + +--- + +## Task 27: Update roadmap, ISSUES, CLAUDE.md, memory + +**Files:** +- Modify: `docs/plans/2026-04-11-roadmap.md` +- Modify: `docs/ISSUES.md` +- Modify: `CLAUDE.md` +- Create: `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_phase_a5_state.md` +- Modify: `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\MEMORY.md` + +- [ ] **Step 1: Add A.5 SHIPPED row to roadmap** + +In `docs/plans/2026-04-11-roadmap.md` "Phases already shipped" table: + +```markdown +| A.5 | Two-tier streaming + horizon LOD — N₁=4 (full detail, 81 LBs) + N₂=12 (terrain only, 544 LBs); fog blend at N₁; per-LB entity dispatcher walk tightened (Change #1 animated-walk fix + Change #2 cached AABB); single-worker off-thread mesh build; mipmaps + 16x anisotropic on TerrainAtlas; A2C with MSAA 4x on foliage; depth-write audit + lock-in test. Acceptance: . Spec at `docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`. Plan archived at `docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md`. | Live ✓ | +``` + +Move A.5 from "Phases ahead" to shipped. + +Update "Currently in flight" pointer: +```markdown +**Currently in flight: Phase N.6 — Perf polish.** +``` +(or whatever phase comes next.) + +- [ ] **Step 2: Close A.5-related issues in `docs/ISSUES.md`** + +Move any A.5-prefixed open issues to "Recently closed" with the SHIP commit +SHA. (If none exist, skip.) + +- [ ] **Step 3: Update `CLAUDE.md` "Currently in flight" line** + +Find the section after "Currently in flight: Phase N.6 — Perf polish." and +update if needed. Update the WB integration cribs section to note A.5's +two-tier streaming wiring location for future readers. + +- [ ] **Step 4: Write memory entry** + +Create `memory/project_phase_a5_state.md`: + +```markdown +--- +name: "Project: Phase A.5 state (shipped )" +description: A.5 shipped two-tier streaming with N₁=4 / N₂=12, fog-tuned horizon, single-worker off-thread mesh build, entity bucketing tightening, mipmaps + A2C + depth-audit. Three high-value gotchas captured. +type: project +--- + +**Phase A.5 — Two-tier Streaming + Horizon LOD — shipped .** + + + +## Three high-value gotchas surfaced during A.5 + +1. +2. +3. + +## Files added or modified summary + +**Added:** +- src/AcDream.App/Streaming/LandblockStreamTier.cs +- src/AcDream.App/Streaming/TwoTierDiff.cs +- tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs +- tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs +- tests/AcDream.Core.Tests/Streaming/GpuWorldStateTwoTierTests.cs +- tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs +- tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs +- tests/AcDream.Core.Tests/Rendering/Wb/WbDispatcherDepthMaskTests.cs +- docs/plans/2026-05-09-phase-a5-perf-baseline.md +- docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md +- docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md + +**Modified:** +- src/AcDream.App/Streaming/StreamingRegion.cs (two-radii + TwoTierDiff) +- src/AcDream.App/Streaming/StreamingController.cs (two-tier Tick) +- src/AcDream.App/Streaming/LandblockStreamer.cs (worker thread + mesh build) +- src/AcDream.App/Streaming/LandblockStreamJob.cs (Loaded.Tier + MeshData; Promoted) +- src/AcDream.App/Streaming/GpuWorldState.cs (RemoveEntities/AddEntitiesToExisting; AnimatedById) +- src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs (WalkEntities + Change #1 + cached AABB) +- src/AcDream.App/Rendering/TerrainModernRenderer.cs (AddLandblockWithMesh) +- src/AcDream.App/Rendering/TerrainAtlas.cs (mipmaps + anisotropic) +- src/AcDream.App/Rendering/Shaders/mesh_modern.frag (A2C output) +- src/AcDream.App/Rendering/GameWindow.cs (MSAA 4x + fog wiring + two-tier construction) +- src/AcDream.Core/World/WorldEntity.cs (AABB cache) +- src/AcDream.Core/World/LandblockLoader.cs (RefreshAabb at register) +- src/AcDream.Core/Terrain/LandblockMesh.cs (IDictionary surfaceCache) +``` + +Update `MEMORY.md` index with one-line pointer: + +```markdown +- [Project: Phase A.5 state](project_phase_a5_state.md) — A.5 SHIPPED . Two-tier streaming N₁=4 / N₂=12, ~2.3km fog horizon, off-thread mesh build, entity bucketing tightening, mipmaps + A2C + depth audit. +``` + +- [ ] **Step 5: Commit** + +```bash +git add docs/plans/2026-04-11-roadmap.md docs/ISSUES.md CLAUDE.md +git commit -m "docs(A.5 T27): roadmap + ISSUES + CLAUDE.md updates for A.5 ship" +``` + +(Memory files are outside the worktree at `~/.claude/projects/.../memory/`. +Memory commits use the same git instance — same `git add` + `git commit`, +just paths under `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\`.) + +--- + +## Task 28: SHIP commit + +**Files:** none (marker commit) + +- [ ] **Step 1: Final build + full test pass** + +Run: `dotnet build && dotnet test --no-build` +Expected: build succeeded; **all** tests pass. + +- [ ] **Step 2: N.5b sentinel re-run** + +Run: `dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"` +Expected: 89+ passing, 0 failures. + +- [ ] **Step 3: SHIP commit** + +```bash +git commit --allow-empty -m "$(cat <<'EOF' +phase(A.5): SHIP — two-tier streaming + horizon LOD + +Acceptance: +- Standstill at Holtburg (30s, NearRadius=4, FarRadius=12): + median ms (target ≤ 4.166ms = 240Hz). p99 ms. +- Walking Holtburg → North Yanshi (60s): + median FPS (target ≥ 144 FPS). p95 FPS. +- Visual gate: horizon visible at ~2.3km; fog blend smooths N₁ + scenery boundary; no shimmer at distance; smooth tree edges; no + new depth artifacts. +- N.5b conformance sentinel: 89+ passing, 0 failures. + +Decisions (per spec §4): +- N₁=4 (full-detail, 81 LBs), N₂=12 (terrain-only, 544 LBs). +- Bucketing Change #1 (animated-walk fix) + Change #2 (cached AABB) + shipped. Change #3 (sub-LB cell cull) NOT shipped — budget hit + without it. +- Single-worker off-thread mesh build (Q6 Option A). +- Hysteresis radius+2 on both tiers (Q7 Option A). +- Mipmaps + 16x anisotropic + A2C with MSAA 4x + depth-write audit + all shipped (Q8 Option C). +- Acceptance gate: Q9 Option B (tiered — strict standstill, relaxed + walking). + +Spec: docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md +Plan: docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md +Perf baseline: docs/plans/2026-05-09-phase-a5-perf-baseline.md + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Self-review checklist + +Spec coverage cross-check: + +| Spec section | Implementing tasks | +|---|---| +| §3 Two-tier streaming model | T1, T3-T6 (StreamingRegion), T13-T16 (StreamingController + GameWindow) | +| §4.1 Tier enum | T1 | +| §4.2 StreamingRegion two-radii | T3-T6 | +| §4.3 StreamingController routing | T13 | +| §4.4 LandblockStreamResult variants | T7 | +| §4.5 Worker thread mesh build | T9 (cache), T10 (lock), T11 (activate), T12 (inject) | +| §4.6 Bucketing Change #1 (animated-walk fix) | T17 | +| §4.6 Bucketing Change #2 (cached AABB) | T8 (schema), T18 (use + populate) | +| §4.6 Bucketing Change #3 (sub-LB cull) | conditional — added as T18.5 only if Tasks 17+18 don't hit 2.0ms budget | +| §4.7 TerrainModernRenderer | T15 (AddLandblockWithMesh entry); no structural change | +| §4.8 Fog tuning | T22 | +| §4.9.1 Mipmaps | T19 | +| §4.9.2 A2C with MSAA | T20 | +| §4.9.3 Depth-write audit | T21 | +| §6 Threading model | T9, T10, T11, T12 | +| §7 Error handling | inherited from existing patterns; spot-checks during T11/T12 | +| §8 Testing strategy | T3-T6, T8, T13, T14, T17, T21 (per-task tests) | +| §2 Acceptance metrics | T23 (logging), T24 (before), T25 (after), T26 (visual gate) | +| §11 Wrap-up | T27, T28 | + +Placeholder scan: only intentional `` markers in baseline doc + memory +entry + SHIP commit message — these are runtime-captured numbers / dates +documented as fillable at Tasks 24, 25, 27, 28. + +Type consistency: +- `LandblockStreamJobKind`: `LoadFar` / `LoadNear` / `PromoteToNear` ✓ +- `TwoTierDiff`: `ToLoadFar` / `ToLoadNear` / `ToPromote` / `ToDemote` / `ToUnload` ✓ +- `LandblockStreamResult.Loaded(LandblockId, Tier, Landblock, MeshData)` ✓ +- `LandblockStreamResult.Promoted(LandblockId, Entities)` ✓ +- `WorldEntity` adds `AabbMin` / `AabbMax` / `AabbDirty` / `RefreshAabb()` / `SetPosition()` ✓ +- `GpuWorldState`: `RemoveEntitiesFromLandblock` / `AddEntitiesToExistingLandblock` ✓ +- `TerrainModernRenderer.AddLandblockWithMesh(lb, meshData)` ✓ +- `WbDrawDispatcher.WalkEntities(entries, frustum, neverCullLb, visibleCells, animatedSet)` returning `WalkResult` ✓ + +All consistent across tasks.