acdream/docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md
Erik a28a5b7583 docs(A.5 T27): spec + plan amendments for T22.5 + ship
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>
2026-05-10 10:06:26 +02:00

85 KiB
Raw Permalink Blame History

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 build from worktree root.
  • Test: dotnet test --no-build (full suite); filter via --filter "FullyQualifiedName~<pat>" 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) <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 LandblockStreamResult with 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.HandleJob to 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 _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<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 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<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 _datsLock field 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 _buildMeshOrNull constructor 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 HandleJob to build mesh + post Loaded with 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 LandblockStreamer construction in GameWindow

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 StreamingController for 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 AddLandblock to delegate to AddLandblockInternal

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 WalkEntities helper

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

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_COVERAGE around 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.frag for 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.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
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 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:

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 (add Quality field)
    • NOTE: SettingsState.cs (from the original inline spec) did not exist; Quality went onto DisplaySettings instead — the natural home for display-related settings.
  • Modify: src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs (Display tab Quality dropdown)
    • NOTE: the original inline spec named DisplayTab.cs; the actual file is SettingsPanel.cs with a RenderDisplayTab method. Same intent, different file name.
  • Modify: src/AcDream.App/Rendering/GameWindow.cs (apply preset on launch + on mid-session change via ReapplyQualityPreset)
  • 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:

  1. DisplaySettings.Quality persists via the existing settings.json infrastructure (Phase L.0).
  2. SettingsPanel.RenderDisplayTab Combo widget for Quality dropdown.
  3. GameWindow.OnLoad applies preset: streamer + controller built with preset's NearRadius/FarRadius; TerrainAtlas.SetAnisotropic from preset; WindowOptions.Samples from preset (window creation time only); WbDrawDispatcher.AlphaToCoverage from preset; StreamingController.MaxCompletionsPerFrame from preset.
  4. Env-var overrides applied per field via WithEnvOverrides; logged at startup.
  5. Mid-session change via F11 → Quality dropdown → ReapplyQualityPreset rebuilds 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 +Acdream and 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:

  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:

| 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)
  • 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.