# 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.