Spec (2026-05-09-phase-a5-two-tier-streaming-design.md): - §2 acceptance metrics reshaped from absolute 240 FPS to refresh-rate-relative + per-preset (95th-pct ≤ 1000ms/refresh standstill; ≤ 1.5× walking) to match the Quality Preset reality. - New §4.10 Quality Preset System (T22.5): enum Low/Medium/High/Ultra, QualitySettings schema, canonical preset values table, env-var override table, wiring notes (GameWindow.OnLoad + ReapplyQualityPreset), MSAA mid-session unsupported caveat, file list, test count (12). - New §11 What was deferred: 8 items (Tier 1 cache, lifestone, JobKind plumbing, Tier 2/3, ToEntries alloc, InvalidateEntity wiring, High preset retest). Former §11 References renumbered to §12. Plan (2026-05-09-phase-a5-two-tier-streaming.md): - New Task 22.5 section inserted between T22 and T23: full inline spec with schema, preset table, env-var list, wiring steps, acceptance criteria, deferred items, commit SHAs. Includes file-name corrections (SettingsState → DisplaySettings, DisplayTab → SettingsPanel). - Self-review cross-check table: new §4.10 row pointing at T22.5. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
85 KiB
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
Conventions
- Working dir:
C:\Users\erikn\source\repos\acdream\.claude\worktrees\hopeful-darwin-ae8b87(this worktree). - Branch:
claude/hopeful-darwin-ae8b87. - Build:
dotnet buildfrom worktree root. - Test:
dotnet test --no-build(full suite); filter via--filter "FullyQualifiedName~<pat>"for targeted runs. - Commits: prefix
phase(A.5):orfeat(A.5):/test(A.5):/fix(A.5):/docs(A.5):per task type. End withCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>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
namespace AcDream.App.Streaming;
/// <summary>
/// Streaming-tier classification for a landblock. <see cref="Far"/> means
/// terrain mesh only; <see cref="Near"/> means terrain + scenery + EnvCells +
/// entity registration with the WB dispatcher. Per Phase A.5 spec §3.
/// </summary>
public enum LandblockStreamTier
{
Far,
Near,
}
/// <summary>
/// What work the streaming worker should perform for a given job. Distinct
/// from <see cref="LandblockStreamTier"/> because <see cref="PromoteToNear"/>
/// reads only the entity layer (terrain mesh already loaded), while
/// <see cref="LoadNear"/> reads everything from scratch. Per Phase A.5 spec §4.3.
/// </summary>
public enum LandblockStreamJobKind
{
/// <summary>Read LandBlock heightmap, build mesh, no entity layer.</summary>
LoadFar,
/// <summary>Read LandBlock + LandBlockInfo, generate scenery, build mesh, full entity layer.</summary>
LoadNear,
/// <summary>Read LandBlockInfo + scenery only — terrain already loaded for this LB.</summary>
PromoteToNear,
}
- Step 2: Build to verify
Run: dotnet build
Expected: Build succeeded. 0 errors.
- Step 3: Commit
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
using System.Collections.Generic;
namespace AcDream.App.Streaming;
/// <summary>
/// Output of <see cref="StreamingRegion.RecenterTo"/> for the two-tier model.
/// Five disjoint lists describe what changed since the previous Tick. Per
/// Phase A.5 spec §4.2.
/// </summary>
public readonly record struct TwoTierDiff(
IReadOnlyList<uint> ToLoadFar, // entered far window from null (terrain only)
IReadOnlyList<uint> ToLoadNear, // entered near window from null (terrain + entities — first-tick or teleport)
IReadOnlyList<uint> ToPromote, // entered near window from far-resident (entities only)
IReadOnlyList<uint> ToDemote, // exited near window past hysteresis (drop entities)
IReadOnlyList<uint> ToUnload); // exited far window past hysteresis (drop terrain)
- Step 2: Modify
LandblockStreamJob.cs
Change the Load record from:
public sealed record Load(uint LandblockId) : LandblockStreamJob(LandblockId);
to:
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:
HandleJob(new LandblockStreamJob.Load(landblockId));
to:
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
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
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):
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:
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
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:
[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:
/// <summary>
/// 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 <see cref="StreamingController.Tick"/> on the first call before any
/// RecenterTo.
/// </summary>
public TwoTierDiff ComputeFirstTickDiff()
{
var near = new List<uint>();
var far = new List<uint>();
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<uint>(),
ToDemote: System.Array.Empty<uint>(),
ToUnload: System.Array.Empty<uint>());
}
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
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)
[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:
internal enum TierResidence { None, Far, Near }
private readonly Dictionary<uint, TierResidence> _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);
/// <summary>
/// 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.
/// </summary>
public TwoTierDiff RecenterTo(int newCx, int newCy)
{
int nearUnloadThreshold = NearRadius + 2;
int farUnloadThreshold = FarRadius + 2;
var toLoadFar = new List<uint>();
var toLoadNear = new List<uint>();
var toPromote = new List<uint>();
var toDemote = new List<uint>();
var toUnload = new List<uint>();
// Pass 1: walk new far window — emit ToLoadFar / ToLoadNear / ToPromote.
var newCenterIds = new HashSet<uint>();
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
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
[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
[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
[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
[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
[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
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
LandblockStreamResultwith extended variants
In LandblockStreamJob.cs, replace the existing LandblockStreamResult
record block:
using System.Collections.Generic;
using AcDream.Core.Terrain;
using AcDream.Core.World;
public abstract record LandblockStreamResult(uint LandblockId)
{
/// <summary>
/// A landblock load completed. <see cref="Tier"/> distinguishes Far
/// (terrain only) from Near (terrain + entities). <see cref="MeshData"/>
/// is built off the render thread on the streaming worker.
/// </summary>
public sealed record Loaded(
uint LandblockId,
LandblockStreamTier Tier,
LoadedLandblock Landblock,
LandblockMeshData MeshData
) : LandblockStreamResult(LandblockId);
/// <summary>
/// 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.
/// </summary>
public sealed record Promoted(
uint LandblockId,
IReadOnlyList<WorldEntity> 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.HandleJobto compile (placeholder MeshData)
In LandblockStreamer.HandleJob (line ~167), update the Loaded construction:
// 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
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
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<MeshRef>(),
};
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<MeshRef>(),
};
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:
// 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
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
_surfaceCacheowner (find via grep) -
Step 1: Locate the
_surfaceCacheowner
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.Buildparameter toIDictionary<uint, SurfaceInfo>
In LandblockMesh.cs, change:
public static LandblockMeshData Build(
LandBlock block,
uint landblockX,
uint landblockY,
float[] heightTable,
TerrainBlendingContext ctx,
Dictionary<uint, SurfaceInfo> surfaceCache)
to:
public static LandblockMeshData Build(
LandBlock block,
uint landblockX,
uint landblockY,
float[] heightTable,
TerrainBlendingContext ctx,
System.Collections.Generic.IDictionary<uint, SurfaceInfo> surfaceCache)
The lookup pattern in Build (lines ~108-112) is:
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<uint, SurfaceInfo>
private readonly System.Collections.Concurrent.ConcurrentDictionary<uint, SurfaceInfo> _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
git add src/AcDream.Core/Terrain/LandblockMesh.cs <surface-cache-owner.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
DatCollectionis owned + accessed (likelyGameWindow.csand various spawn handlers).
Background: Per LandblockStreamer.cs:18-27 comments, DatCollection
is not thread-safe. A.5 needs the worker to call _dats.Get<LandBlock> /
_dats.Get<LandBlockInfo> 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
_datsLockfield next to the DatCollection field
private readonly object _datsLock = new();
- Step 3: Wrap each
_dats.Get<T>(...)access in the lock
Two patterns acceptable:
(a) Inline lock at each call site:
LandBlock? block;
lock (_datsLock) { block = _dats.Get<LandBlock>(id); }
(b) Helper method:
private T? GetDat<T>(uint id) where T : class
{
lock (_datsLock) { return _dats.Get<T>(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
git add <modified-files>
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():
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:
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:
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
git add src/AcDream.App/Streaming/LandblockStreamer.cs <call-sites>
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
_buildMeshOrNullconstructor param + field
In LandblockStreamer.cs:
private readonly Func<uint, LandblockMeshData?> _buildMeshOrNull;
public LandblockStreamer(
Func<uint, LoadedLandblock?> loadLandblock,
Func<uint, LandblockMeshData?> buildMeshOrNull)
{
_loadLandblock = loadLandblock;
_buildMeshOrNull = buildMeshOrNull;
_inbox = Channel.CreateUnbounded<LandblockStreamJob>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
_outbox = Channel.CreateUnbounded<LandblockStreamResult>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
}
- Step 2: Update
HandleJobto build mesh + postLoadedwith Tier + MeshData
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
LandblockStreamerconstruction inGameWindow
In GameWindow.cs, locate the _streamer = new LandblockStreamer(...) line.
Update:
_streamer = new LandblockStreamer(
loadLandblock: id =>
{
lock (_datsLock) { return LandblockLoader.Load(_dats, id); }
},
buildMeshOrNull: id =>
{
LandBlock? block;
lock (_datsLock) { block = _dats.Get<LandBlock>(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
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):
public void RemoveEntitiesFromLandblock(uint landblockId)
{
throw new System.NotImplementedException("A.5 T14");
}
public void AddEntitiesToExistingLandblock(uint landblockId, System.Collections.Generic.IReadOnlyList<WorldEntity> entities)
{
throw new System.NotImplementedException("A.5 T14");
}
- Step 2: Rewrite
StreamingControllerfor two-tier
Replace the existing constructor and Tick:
private readonly Action<uint, LandblockStreamJobKind> _enqueueLoad;
private readonly Action<uint> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
private readonly Action<uint>? _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<uint, LandblockStreamJobKind> enqueueLoad,
Action<uint> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
Action<LoadedLandblock, LandblockMeshData> applyTerrain,
GpuWorldState state,
int nearRadius,
int farRadius,
Action<uint>? 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)
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<uint>();
var completions = new List<LandblockStreamResult>();
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
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
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<MeshRef>() },
new WorldEntity { Id = 2, MeshRefs = System.Array.Empty<MeshRef>() },
});
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<MeshRef>() } });
state.AddLandblock(lb);
state.AddEntitiesToExistingLandblock(0xAAAA_FFFF, new[]
{
new WorldEntity { Id = 2, MeshRefs = System.Array.Empty<MeshRef>() },
new WorldEntity { Id = 3, MeshRefs = System.Array.Empty<MeshRef>() },
});
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:
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<WorldEntity>());
_pendingByLandblock.Remove(landblockId);
RebuildFlatView();
}
public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList<WorldEntity> 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<WorldEntity>();
_pendingByLandblock[landblockId] = bucket;
}
bucket.AddRange(entities);
return;
}
var merged = new List<WorldEntity>(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
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
AddLandblockto delegate toAddLandblockInternal
Today's AddLandblock(LoadedLandblock lb) builds the mesh and adds it.
Refactor:
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
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:
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
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
WalkEntitieshelper
In WbDrawDispatcher.cs, extract a testable helper:
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<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
FrustumPlanes? frustum,
uint? neverCullLandblockId,
HashSet<uint>? visibleCellIds,
HashSet<uint>? 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.Drawto useWalkEntities
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.LandblockEntriesto yieldAnimatedById
In GpuWorldState.cs, modify LandblockEntries to compute and yield
AnimatedById:
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? 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<uint, WorldEntity>(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
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<WorldEntity>();
for (int i = 0; i < 1000; i++)
entities.Add(new WorldEntity { Id = (uint)i, MeshRefs = System.Array.Empty<MeshRef>() });
var animatedById = new Dictionary<uint, WorldEntity> { [42] = entities[42] };
var animatedSet = new HashSet<uint> { 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<WorldEntity>)entities,
AnimatedById: (IReadOnlyDictionary<uint, WorldEntity>?)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
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():
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<MeshRef>(),
};
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:
// 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
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:
_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
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:
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_COVERAGEaround the opaque pass
In WbDrawDispatcher.Draw, around the opaque pass (line ~400):
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.fragfor A2C-friendly output
Find the ClipMap branch. Replace:
if (texColor.a < 0.5) discard;
outColor = vec4(texColor.rgb, 1.0);
with:
// 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
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.Drawdepth-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
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
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 whereverSceneLightingUbois updated per frame) -
Step 1: Locate
SceneLightingUboupdate 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:
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:
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
git add <modified-files>
git commit -m "feat(A.5 T22): fog params wired from N₁/N₂ + ACDREAM_FOG_*_MULT env vars"
Task 22.5 (NEW — Quality Preset System)
Inserted between T22 (fog wiring) and T23 (DIAG budgets). Added mid-execution at user's direction. Estimate: ~1 day.
Background: User added this task between T22 and T23 with a complete inline spec. Shipped as commits afa4200 (schema + tests) and 28d2c60 (wiring). Design spec at §4.10 of the A.5 spec doc.
Files:
- Create:
src/AcDream.UI.Abstractions/Settings/QualityPreset.cs - Modify:
src/AcDream.UI.Abstractions/Settings/DisplaySettings.cs(addQualityfield)- NOTE:
SettingsState.cs(from the original inline spec) did not exist;Qualitywent ontoDisplaySettingsinstead — the natural home for display-related settings.
- NOTE:
- Modify:
src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs(Display tab Quality dropdown)- NOTE: the original inline spec named
DisplayTab.cs; the actual file isSettingsPanel.cswith aRenderDisplayTabmethod. Same intent, different file name.
- NOTE: the original inline spec named
- Modify:
src/AcDream.App/Rendering/GameWindow.cs(apply preset on launch + on mid-session change viaReapplyQualityPreset) - Create:
tests/AcDream.UI.Abstractions.Tests/Settings/QualityPresetTests.cs
Schema:
public enum QualityPreset { Low, Medium, High, Ultra }
public readonly record struct QualitySettings(
int NearRadius, int FarRadius,
int MsaaSamples, int AnisotropicLevel,
bool AlphaToCoverage,
int MaxCompletionsPerFrame);
QualitySettings.From(preset) returns canonical values per preset:
| Preset | NearRadius | FarRadius | MsaaSamples | AnisotropicLevel | AlphaToCoverage | MaxCompletionsPerFrame |
|---|---|---|---|---|---|---|
| Low | 2 | 5 | 0 | 4 | false | 2 |
| Medium | 3 | 8 | 2 | 8 | false | 3 |
| High | 4 | 12 | 4 | 16 | true | 4 |
| Ultra | 5 | 15 | 4 | 16 | true | 6 |
QualitySettings.WithEnvOverrides(baseSettings) applies per-field env-var overrides:
ACDREAM_NEAR_RADIUS, ACDREAM_FAR_RADIUS, ACDREAM_MSAA_SAMPLES,
ACDREAM_ANISOTROPIC, ACDREAM_A2C, ACDREAM_MAX_COMPLETIONS_PER_FRAME.
Wiring:
DisplaySettings.Qualitypersists via the existingsettings.jsoninfrastructure (Phase L.0).SettingsPanel.RenderDisplayTabCombo widget for Quality dropdown.GameWindow.OnLoadapplies preset: streamer + controller built with preset'sNearRadius/FarRadius;TerrainAtlas.SetAnisotropicfrom preset;WindowOptions.Samplesfrom preset (window creation time only);WbDrawDispatcher.AlphaToCoveragefrom preset;StreamingController.MaxCompletionsPerFramefrom preset.- Env-var overrides applied per field via
WithEnvOverrides; logged at startup. - Mid-session change via F11 → Quality dropdown →
ReapplyQualityPresetrebuilds the streaming pipeline. MSAA samples mid-session change is structurally unsupported (OpenGL requires window recreation); logs a warning.
Acceptance criteria (as shipped):
- Standstill: at user's selected preset, 95% of frames hit ≤ (1000ms / monitor refresh).
- Walking: 95% ≤ 1.5× (1000ms / monitor refresh).
- Visual gate: same on all presets.
Out of scope (deferred):
- Auto-detect first-launch preset (Phase A.6 / N.6.5).
- Adaptive runtime preset drop on budget miss.
- Per-feature toggles below preset level.
Commits: afa4200 (schema + tests), 28d2c60 (wiring).
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:
Console.WriteLine(
$"[WB-DIAG] entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} ...");
with:
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
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
$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
+Acdreamand stands at Holtburg dueling field 30s
Then close the window.
- Step 3: Read
[WB-DIAG]from the log
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
# Phase A.5 — perf baseline
## Before (radius=5 single-tier, today's behavior)
**Captured:** <date> at Holtburg dueling field, NearRadius=5, FarRadius=5,
30s standstill.
| Subsystem | cpu_us median | cpu_us p95 |
|---|---|---|
| Entity dispatcher | <fill> | <fill> |
| Terrain dispatcher | <fill> | <fill> |
Frame time: <fill> ms median.
Effective FPS: <fill>.
This is the "before" anchor. Task 25 captures the "after" comparison.
- Step 5: Commit
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
# 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
## After (Phase A.5: N₁=4, N₂=12, full bucketing + threading + visual)
**Captured:** <date>, full A.5.
### Standstill (30s, Holtburg dueling field)
| Subsystem | cpu_us median | cpu_us p95 |
|---|---|---|
| Entity dispatcher | <fill> | <fill> |
| Terrain dispatcher | <fill> | <fill> |
Frame time: <fill> ms median, <fill> ms p99.
Effective FPS: <fill> 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 | <fill> | <fill> |
| Terrain dispatcher | <fill> | <fill> |
Frame time: <fill> ms median, <fill> ms p95.
Effective FPS: <fill> median.
**Acceptance criterion 3 (median ≥ 144 FPS):** PASS / FAIL.
- Step 4: Commit
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:
- Horizon visible at ~2.3 km. ✓ / ✗
- Fog blend at N₁ smooths the scenery boundary (no harsh cliff). ✓ / ✗
- Distant terrain does not shimmer (mipmaps work). ✓ / ✗
- Tree edges are smooth (A2C works, if shipped). ✓ / ✗
- 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:
| 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: <fill from baseline>. 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:
**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:
---
name: "Project: Phase A.5 state (shipped <date>)"
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 <date>.**
<short summary mirroring N.5b memory pattern; concrete numbers from the
perf baseline doc>
## Three high-value gotchas surfaced during A.5
1. <gotcha 1 — typically the most surprising>
2. <gotcha 2>
3. <gotcha 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:
- [Project: Phase A.5 state](project_phase_a5_state.md) — A.5 SHIPPED <date>. 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
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
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 <fill>ms (target ≤ 4.166ms = 240Hz). p99 <fill>ms.
- Walking Holtburg → North Yanshi (60s):
median <fill> FPS (target ≥ 144 FPS). p95 <fill> 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) <noreply@anthropic.com>
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.10 Quality Preset System (NEW — mid-execution addition) | T22.5 |
| §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 <fill> 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)✓WorldEntityaddsAabbMin/AabbMax/AabbDirty/RefreshAabb()/SetPosition()✓GpuWorldState:RemoveEntitiesFromLandblock/AddEntitiesToExistingLandblock✓TerrainModernRenderer.AddLandblockWithMesh(lb, meshData)✓WbDrawDispatcher.WalkEntities(entries, frustum, neverCullLb, visibleCells, animatedSet)returningWalkResult✓
All consistent across tasks.