Review follow-up from commit 11df793. Three fixes:
1. Visible semantics: StreamingRegion.Visible now strictly describes the
current (2r+1)×(2r+1) window, not window + hysteresis retainees.
Added a parallel Resident property exposing the actual loaded set
(window + hysteresis buffer). This matters because StreamingController
(next task) reads these to decide what to render vs what to unload;
conflating them in one set would have forced awkward post-processing
downstream.
2. Doc/code disagreement: updated the RecenterTo and RegionDiff doc
comments from "radius + 1" to "radius + 2" to match the actual
implementation (which is what the tests require). Also updated the
plan doc so future readers don't hit the same contradiction.
3. Edge-clamping test coverage: added a single-axis edge test
(cx=0, cy=50 → 15 entries) and an ID-encoding test (radius=0 at
0x12,0x34 → 0x1234FFFE) so a swapped-shift bug in EncodeLandblockId
or an asymmetric off-by-one would fail a test instead of passing
silently.
9 tests green, full suite regressions-free.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
60 KiB
Phase A.1 — Streaming Landblock Loader 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: Replace acdream's one-shot 3×3 landblock preload with a streaming loader that follows the camera (offline) or player (live), loads new landblocks on a background worker, and unloads landblocks that fall outside a configurable visible window.
Architecture: Four new classes (StreamingRegion, LandblockStreamer, GpuWorldState, StreamingController) plus incremental additions to TerrainRenderer and GameWindow. Loads run on a dedicated worker thread driven by System.Threading.Channels.Channel<T>; GPU upload stays on the render thread draining a completion outbox in OnUpdate. Window radius is runtime-configurable via ACDREAM_STREAM_RADIUS (default 2 → 5×5 visible window) with hysteresis of radius + 2 to prevent churn at boundary crossings.
Tech Stack: .NET 10, Silk.NET, DatReaderWriter, System.Threading.Channels, xUnit.
Spec: docs/superpowers/specs/2026-04-11-foundation-phase-design.md
Out of scope for this plan (future plans will cover):
- Frustum culling (Phase A.2 — separate plan)
- Background net I/O thread (Phase A.3 — separate plan)
- Per-entity LOD / fine-grained culling
File structure
src/AcDream.App/
Streaming/ [new folder]
StreamingRegion.cs [new] pure data: window set + diff
LandblockStreamer.cs [new] worker thread + channels
LandblockStreamJob.cs [new] job + completion records
GpuWorldState.cs [new] owns per-landblock GPU state
StreamingController.cs [new] glue called from OnUpdate
Rendering/
TerrainRenderer.cs [modify] add RemoveLandblock
src/AcDream.App/
GameWindow.cs [modify] wire controller, remove preload,
drop _entities flat list
tests/AcDream.Core.Tests/
Streaming/ [new folder]
StreamingRegionTests.cs [new] pure-function tests
LandblockStreamerTests.cs [new] worker tests with fake loader
StreamingControllerTests.cs [new] controller logic with fakes
Core reasons for this split:
StreamingRegionis a pure value type with no dependencies — easy TDD.LandblockStreamertakes aFunc<uint, LoadedLandblock?>load delegate instead of aDatCollection, so tests can inject a fake without touching the dat subsystem.GpuWorldStateowns mutable per-landblock GPU-side state but is driven entirely by the render thread — no locking needed.StreamingControlleris the glue. Tests inject a fakeLandblockStreamerimplementation so we can verify load/unload decisions without a real worker.
Task 1: StreamingRegion (pure data class + diff)
Files:
- Create:
src/AcDream.App/Streaming/StreamingRegion.cs - Test:
tests/AcDream.Core.Tests/Streaming/StreamingRegionTests.cs
Note: tests live under AcDream.Core.Tests even though the class is in AcDream.App because we want a dependency-free unit test. Add a project reference from the test project to AcDream.App via a new <ProjectReference> in the next task if one doesn't already exist — step 0 below verifies.
- Step 0: Check test project has a ref to AcDream.App
Run: grep -n "AcDream.App" tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj
Expected: no match (acdream Core tests don't currently reference the App project).
If no match: add the project reference. Open tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj and add to the <ItemGroup> containing other <ProjectReference> entries:
<ProjectReference Include="..\..\src\AcDream.App\AcDream.App.csproj" />
If AcDream.App has OpenGL-specific types that can't load in a unit-test context, the alternative is to put StreamingRegion in AcDream.Core instead. The class has no Silk.NET dependencies so both locations work; AcDream.App/Streaming/ keeps the streaming subsystem together which is the preferred shape. If the project reference causes test load failures, move StreamingRegion.cs and the test file to AcDream.Core/Streaming/ and delete the new project reference.
- Step 1: Write the failing test — basic 5×5 window at a safe center
Create tests/AcDream.Core.Tests/Streaming/StreamingRegionTests.cs:
using AcDream.App.Streaming;
using Xunit;
namespace AcDream.Core.Tests.Streaming;
public class StreamingRegionTests
{
[Fact]
public void Constructor_Radius2_Produces25Landblocks()
{
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
Assert.Equal(25, region.Visible.Count);
}
}
- Step 2: Run test — verify it fails with "class not found"
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~StreamingRegionTests"
Expected: FAIL with build error referring to missing AcDream.App.Streaming.StreamingRegion.
- Step 3: Create
StreamingRegionwith minimal API
Create src/AcDream.App/Streaming/StreamingRegion.cs:
using System.Collections.Generic;
namespace AcDream.App.Streaming;
/// <summary>
/// Pure data type describing the set of landblocks currently considered
/// "visible" by the streaming system. Given a center landblock (x, y) and
/// a radius, builds the set of landblock IDs in the (2r+1)×(2r+1) window.
/// </summary>
public sealed class StreamingRegion
{
public int CenterX { get; private set; }
public int CenterY { get; private set; }
public int Radius { get; }
private readonly HashSet<uint> _visible = new();
/// <summary>
/// Landblock IDs (8.8 coordinate form: <c>(lbX << 24) | (lbY << 16) | 0xFFFE</c>)
/// in the current visible window.
/// </summary>
public IReadOnlyCollection<uint> Visible => _visible;
public StreamingRegion(int cx, int cy, int radius)
{
Radius = radius;
Recenter(cx, cy);
}
private void Recenter(int cx, int cy)
{
CenterX = cx;
CenterY = cy;
_visible.Clear();
for (int dx = -Radius; dx <= Radius; dx++)
{
for (int dy = -Radius; dy <= Radius; dy++)
{
int nx = cx + dx;
int ny = cy + dy;
if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF)
continue;
_visible.Add(EncodeLandblockId(nx, ny));
}
}
}
internal static uint EncodeLandblockId(int lbX, int lbY)
=> ((uint)lbX << 24) | ((uint)lbY << 16) | 0xFFFEu;
}
- Step 4: Run test — verify pass
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~StreamingRegionTests"
Expected: PASS 1/1.
- Step 5: Add test — edge clamping at (0, 0)
Append to StreamingRegionTests.cs:
[Fact]
public void Constructor_NearOrigin_ClampsToWorldEdge()
{
// Center at (0, 0) with radius 2: only the +X / +Y quadrant is
// in-bounds. That's a 3×3 subset of the 5×5 window = 9 landblocks.
var region = new StreamingRegion(cx: 0, cy: 0, radius: 2);
Assert.Equal(9, region.Visible.Count);
}
[Fact]
public void Constructor_NearFarEdge_ClampsToWorldEdge()
{
var region = new StreamingRegion(cx: 0xFF, cy: 0xFF, radius: 2);
Assert.Equal(9, region.Visible.Count);
}
- Step 6: Run — verify both pass (no code changes needed)
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~StreamingRegionTests"
Expected: PASS 3/3.
- Step 7: Add the diff API (failing test first)
Append to StreamingRegionTests.cs:
[Fact]
public void RecenterTo_SamePosition_EmptyDiff()
{
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
var diff = region.RecenterTo(50, 50);
Assert.Empty(diff.ToLoad);
Assert.Empty(diff.ToUnload);
}
[Fact]
public void RecenterTo_SingleStepEast_LoadsColumn_NoUnloadsDueToHysteresis()
{
// Radius 2 → unload threshold is radius+2 = 4.
// Starting center (50,50) covers X in [48..52]. Step to (51,50):
// new coverage X in [49..53]. New column is x=53 (5 entries).
// Departing column would be x=48, but |48-51| = 3 which is less than
// the threshold, so it stays loaded (hysteresis keeps radius+2).
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
var diff = region.RecenterTo(51, 50);
Assert.Equal(5, diff.ToLoad.Count);
Assert.Empty(diff.ToUnload);
}
[Fact]
public void RecenterTo_ThreeStepEast_LoadsAndUnloadsColumns()
{
// Starting (50,50) covers X in [48..52]. Step to (53,50):
// new coverage X in [51..55]. New columns: x=53,54,55 (15 entries).
// x=48 is now 5 away → unload. x=49,50 still within radius+2 → keep.
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
var diff = region.RecenterTo(53, 50);
Assert.Equal(15, diff.ToLoad.Count);
Assert.Equal(5, diff.ToUnload.Count);
}
[Fact]
public void RecenterTo_LongTeleport_UnloadsEverythingLoadsEverything()
{
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
var diff = region.RecenterTo(200, 200);
Assert.Equal(25, diff.ToLoad.Count);
Assert.Equal(25, diff.ToUnload.Count);
}
- Step 8: Run — verify they fail for "RecenterTo not defined"
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~StreamingRegionTests"
Expected: FAIL with build error RegionDiff and RecenterTo not defined.
- Step 9: Add the
RegionDiffrecord andRecenterTomethod
Append to StreamingRegion.cs (inside the AcDream.App.Streaming namespace):
/// <summary>
/// Output of <see cref="StreamingRegion.RecenterTo"/>: the landblocks to
/// start loading (newly entered the visible window) and the landblocks to
/// unload (fell outside the unload threshold, which is <c>radius + 2</c>).
/// Both lists are disjoint from the current <see cref="StreamingRegion.Visible"/>
/// set; the caller hands them to <c>LandblockStreamer</c> as jobs.
/// </summary>
public readonly record struct RegionDiff(
IReadOnlyList<uint> ToLoad,
IReadOnlyList<uint> ToUnload);
Add to the StreamingRegion class body (below Recenter):
/// <summary>
/// Recompute the visible window around a new center and return the
/// delta vs. the previous state. Hysteresis: landblocks aren't unloaded
/// until they're further than <c>Radius + 2</c> from the new center,
/// so boundary crossings don't thrash.
/// </summary>
public RegionDiff RecenterTo(int newCx, int newCy)
{
// Snapshot the current visible set so we can diff against it.
var oldVisible = new HashSet<uint>(_visible);
Recenter(newCx, newCy);
// Loads = everything newly in visible but not previously.
var toLoad = new List<uint>();
foreach (var id in _visible)
if (!oldVisible.Contains(id))
toLoad.Add(id);
// Unloads = everything previously visible AND now outside the
// hysteresis threshold (|dx| > r+1 OR |dy| > r+1).
int unloadThreshold = Radius + 2;
var toUnload = new List<uint>();
foreach (var id in oldVisible)
{
if (_visible.Contains(id)) continue; // still visible, not unloading
int lbX = (int)((id >> 24) & 0xFFu);
int lbY = (int)((id >> 16) & 0xFFu);
int dx = System.Math.Abs(lbX - newCx);
int dy = System.Math.Abs(lbY - newCy);
if (dx > unloadThreshold || dy > unloadThreshold)
toUnload.Add(id);
}
// Any "still loaded but outside visible" landblocks not in toUnload
// need to rejoin the visible set so we don't lose track of them.
foreach (var id in oldVisible)
if (!_visible.Contains(id) && !toUnload.Contains(id))
_visible.Add(id);
return new RegionDiff(toLoad, toUnload);
}
- Step 10: Run — verify all 7 tests pass
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~StreamingRegionTests"
Expected: PASS 7/7.
- Step 11: Commit
git add src/AcDream.App/Streaming/StreamingRegion.cs tests/AcDream.Core.Tests/Streaming/StreamingRegionTests.cs tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj
git commit -m "$(cat <<'EOF'
feat(app): Phase A.1 — StreamingRegion (window set + diff with hysteresis)
Pure data type describing the set of landblocks inside the current
streaming window, with a diff-style Recenter that returns (toLoad,
toUnload) pairs the LandblockStreamer consumes as jobs. Hysteresis
of radius+2 prevents load/unload churn at boundary crossings.
First piece of Phase A.1 per docs/superpowers/plans/2026-04-11-foundation-a1-streaming.md.
7 new tests, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)"
Task 2: LandblockStreamJob + LoadedLandblockResult records
Files:
- Create:
src/AcDream.App/Streaming/LandblockStreamJob.cs
These are small record types used by the streamer's channels. No tests — they're data.
- Step 1: Create the file
Create src/AcDream.App/Streaming/LandblockStreamJob.cs:
using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <summary>
/// A job posted to <see cref="LandblockStreamer"/>'s inbox. Either a load
/// (fetch this landblock from the dats and build its CPU-side mesh data)
/// or an unload (release any state tied to this landblock on the render
/// thread's next Tick drain).
/// </summary>
public abstract record LandblockStreamJob(uint LandblockId)
{
public sealed record Load(uint LandblockId) : LandblockStreamJob(LandblockId);
public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId);
}
/// <summary>
/// Outbox record the render thread drains. Either a successful load, a
/// failed load (logged and ignored until region recenters off/back), or
/// an unload notification (tells the render thread to release GPU state
/// for this landblock id).
/// </summary>
public abstract record LandblockStreamResult(uint LandblockId)
{
public sealed record Loaded(uint LandblockId, LoadedLandblock Landblock) : LandblockStreamResult(LandblockId);
public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId);
public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId);
}
- Step 2: Build (no tests; records compile-check is enough)
Run: dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
Expected: build succeeds.
- Step 3: Commit
git add src/AcDream.App/Streaming/LandblockStreamJob.cs
git commit -m "feat(app): Phase A.1 — job + result records for LandblockStreamer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
Task 3: LandblockStreamer (worker thread + channels)
Files:
- Create:
src/AcDream.App/Streaming/LandblockStreamer.cs - Test:
tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs
The streamer owns a background thread that pulls jobs from a channel, invokes a caller-supplied Func<uint, LoadedLandblock?> to load the landblock (the production instance wraps LandblockLoader.Load(dats, id); tests inject a fake), and posts results to an outbox channel the render thread drains.
- Step 1: Write the failing test — basic load → drain round-trip
Create tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs:
using AcDream.App.Streaming;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using Xunit;
namespace AcDream.Core.Tests.Streaming;
public class LandblockStreamerTests
{
[Fact]
public async Task Load_FollowedByDrain_ReturnsLoadedRecord()
{
var stubLandblock = new LoadedLandblock(
0xA9B4FFFEu,
new LandBlock(),
System.Array.Empty<WorldEntity>());
using var streamer = new LandblockStreamer(
loadLandblock: id => id == 0xA9B4FFFEu ? stubLandblock : null);
streamer.Start();
streamer.EnqueueLoad(0xA9B4FFFEu);
// Spin until the worker produces a completion, with a 2s timeout.
LandblockStreamResult? result = null;
for (int i = 0; i < 200 && result is null; i++)
{
var drained = streamer.DrainCompletions(maxBatchSize: 4);
if (drained.Count > 0) result = drained[0];
else await Task.Delay(10);
}
Assert.NotNull(result);
var loaded = Assert.IsType<LandblockStreamResult.Loaded>(result);
Assert.Equal(0xA9B4FFFEu, loaded.LandblockId);
Assert.Same(stubLandblock, loaded.Landblock);
}
}
- Step 2: Run — verify fail with class-not-found
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~LandblockStreamerTests"
Expected: FAIL with build error referring to missing LandblockStreamer.
- Step 3: Create the streamer
Create src/AcDream.App/Streaming/LandblockStreamer.cs:
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <summary>
/// Background worker that services landblock load and unload requests off
/// the render thread. Loads are executed on a dedicated thread via a
/// caller-supplied delegate (the production instance wraps
/// <see cref="LandblockLoader.Load"/>); completed results are posted to
/// an outbox channel the render thread drains once per OnUpdate.
///
/// <para>
/// Unloads are passed through the same channel as a <see cref="LandblockStreamResult.Unloaded"/>
/// record so the render thread can release GPU state on the next drain —
/// the worker never touches GPU resources directly.
/// </para>
/// </summary>
public sealed class LandblockStreamer : IDisposable
{
private readonly Func<uint, LoadedLandblock?> _loadLandblock;
private readonly Channel<LandblockStreamJob> _inbox;
private readonly Channel<LandblockStreamResult> _outbox;
private readonly CancellationTokenSource _cancel = new();
private Thread? _worker;
private bool _disposed;
public LandblockStreamer(Func<uint, LoadedLandblock?> loadLandblock)
{
_loadLandblock = loadLandblock;
_inbox = Channel.CreateUnbounded<LandblockStreamJob>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
_outbox = Channel.CreateUnbounded<LandblockStreamResult>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
}
/// <summary>
/// Start the worker thread. Must be called before enqueueing jobs.
/// Calling twice is a no-op.
/// </summary>
public void Start()
{
if (_worker is not null) return;
_worker = new Thread(WorkerLoop)
{
IsBackground = true,
Name = "acdream.landblock-streamer",
};
_worker.Start();
}
public void EnqueueLoad(uint landblockId)
{
_inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId));
}
public void EnqueueUnload(uint landblockId)
{
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
}
/// <summary>
/// Drain up to <paramref name="maxBatchSize"/> completed results.
/// Non-blocking. Call from the render thread once per OnUpdate.
/// </summary>
public IReadOnlyList<LandblockStreamResult> DrainCompletions(int maxBatchSize = 4)
{
var batch = new List<LandblockStreamResult>(maxBatchSize);
while (batch.Count < maxBatchSize && _outbox.Reader.TryRead(out var result))
batch.Add(result);
return batch;
}
private void WorkerLoop()
{
try
{
// Synchronous read loop via .WaitToReadAsync + ReadAllAsync
// would be idiomatic but requires async; the blocking reader
// is simpler and the thread is dedicated anyway.
while (!_cancel.Token.IsCancellationRequested)
{
if (!_inbox.Reader.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult())
break;
while (_inbox.Reader.TryRead(out var job))
{
if (_cancel.Token.IsCancellationRequested) return;
HandleJob(job);
}
}
}
catch (OperationCanceledException) { /* graceful shutdown */ }
catch (Exception ex)
{
// Last-ditch: surface via outbox so the caller at least sees
// something. We never retry a crashed worker.
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(0, ex.ToString()));
}
finally
{
_outbox.Writer.TryComplete();
}
}
private void HandleJob(LandblockStreamJob job)
{
switch (job)
{
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"));
else
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
load.LandblockId, lb));
}
catch (Exception ex)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, ex.ToString()));
}
break;
case LandblockStreamJob.Unload unload:
_outbox.Writer.TryWrite(new LandblockStreamResult.Unloaded(unload.LandblockId));
break;
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_cancel.Cancel();
_inbox.Writer.TryComplete();
_worker?.Join(TimeSpan.FromSeconds(2));
_cancel.Dispose();
}
}
- Step 4: Run — verify pass
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~LandblockStreamerTests"
Expected: PASS 1/1.
- Step 5: Add test — null return is reported as Failed
Append to LandblockStreamerTests.cs:
[Fact]
public async Task Load_WhenLoaderReturnsNull_ReportsFailed()
{
using var streamer = new LandblockStreamer(
loadLandblock: _ => null);
streamer.Start();
streamer.EnqueueLoad(0x12340000u);
LandblockStreamResult? result = null;
for (int i = 0; i < 200 && result is null; i++)
{
var drained = streamer.DrainCompletions(4);
if (drained.Count > 0) result = drained[0];
else await Task.Delay(10);
}
Assert.NotNull(result);
Assert.IsType<LandblockStreamResult.Failed>(result);
}
[Fact]
public async Task Load_WhenLoaderThrows_ReportsFailedWithMessage()
{
using var streamer = new LandblockStreamer(
loadLandblock: _ => throw new System.InvalidOperationException("boom"));
streamer.Start();
streamer.EnqueueLoad(0x55550000u);
LandblockStreamResult? result = null;
for (int i = 0; i < 200 && result is null; i++)
{
var drained = streamer.DrainCompletions(4);
if (drained.Count > 0) result = drained[0];
else await Task.Delay(10);
}
var failed = Assert.IsType<LandblockStreamResult.Failed>(result);
Assert.Contains("boom", failed.Error);
}
[Fact]
public async Task Unload_ProducesUnloadedResult()
{
using var streamer = new LandblockStreamer(loadLandblock: _ => null);
streamer.Start();
streamer.EnqueueUnload(0xABCD0000u);
LandblockStreamResult? result = null;
for (int i = 0; i < 200 && result is null; i++)
{
var drained = streamer.DrainCompletions(4);
if (drained.Count > 0) result = drained[0];
else await Task.Delay(10);
}
var unloaded = Assert.IsType<LandblockStreamResult.Unloaded>(result);
Assert.Equal(0xABCD0000u, unloaded.LandblockId);
}
- Step 6: Run — verify all four pass
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~LandblockStreamerTests"
Expected: PASS 4/4.
- Step 7: Commit
git add src/AcDream.App/Streaming/LandblockStreamer.cs tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs
git commit -m "$(cat <<'EOF'
feat(app): Phase A.1 — LandblockStreamer (background worker + channels)
Background thread pulls load/unload jobs from an inbox channel, invokes
a caller-supplied Func<uint, LoadedLandblock?> (production wraps
LandblockLoader.Load, tests inject a fake), and posts results to an
outbox channel the render thread drains. Graceful shutdown via
CancellationToken; failed loads reported rather than retried.
4 new tests, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)"
Task 4: TerrainRenderer.RemoveLandblock
Files:
- Modify:
src/AcDream.App/Rendering/TerrainRenderer.cs
The current terrain renderer only supports AddLandblock. Streaming needs to release terrain GPU resources when a landblock unloads. Add a RemoveLandblock(uint landblockId) method that finds the corresponding buffers and deletes them.
- Step 1: Read the current AddLandblock implementation
Run: grep -n "AddLandblock\|class TerrainRenderer\|_landblocks" src/AcDream.App/Rendering/TerrainRenderer.cs
Expected output: the method signature and any internal state storage it uses. Note the field that holds per-landblock GPU state (likely a List<> or Dictionary<uint, ...>).
- Step 2: Add the public RemoveLandblock method
Locate AddLandblock in src/AcDream.App/Rendering/TerrainRenderer.cs. The renderer currently stores landblock GPU handles in an internal collection indexed by landblock id. Immediately after AddLandblock, add:
/// <summary>
/// Release GPU buffers for a previously-added landblock. No-op if the
/// landblock wasn't added. Called by the streaming system when a
/// landblock falls outside the visible window.
/// </summary>
public void RemoveLandblock(uint landblockId)
{
// Locate the existing per-landblock GPU handle by id. If the
// collection is a list, scan for the matching id and remove;
// if a dictionary, TryRemove.
//
// IMPLEMENTATION NOTE: the exact code depends on how AddLandblock
// currently stores its state. Read AddLandblock first, then mirror
// its pattern in reverse: delete Vbo/Ebo/Vao via _gl.Delete*, then
// remove the entry from the collection.
}
Then actually implement the body by reading the current AddLandblock internals. The implementation should call _gl.DeleteBuffer and _gl.DeleteVertexArray for whatever resources AddLandblock created, then remove the entry from the internal collection.
If AddLandblock does not currently take a landblock id as a key (e.g., if it stores unkeyed handles), update AddLandblock's signature to accept uint landblockId and store entries in a Dictionary<uint, ...> keyed by landblock id. Update the single caller in GameWindow.cs in the same commit.
- Step 3: Add a build-only verification (no unit test for a direct-to-GL method)
Run: dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
Expected: build succeeds. TerrainRenderer GL methods can't be unit tested without a GL context; this is verified via the end-to-end run in Task 7.
- Step 4: Commit
git add src/AcDream.App/Rendering/TerrainRenderer.cs
git commit -m "feat(app): Phase A.1 — TerrainRenderer.RemoveLandblock for streaming unloads
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
Task 5: GpuWorldState (per-landblock ownership)
Files:
- Create:
src/AcDream.App/Streaming/GpuWorldState.cs
GpuWorldState owns the authoritative set of loaded landblocks on the render thread. It holds:
- A dictionary of
uint landblockId → LoadedLandblock(CPU-side mesh data) - The flat
IReadOnlyList<WorldEntity>the renderer iterates over, rebuilt lazily when the dict changes - (Phase A.2 will add a per-landblock
BoundingBox; out of scope for this plan)
Operations are render-thread only — no locking needed.
- Step 1: Create the class
Create src/AcDream.App/Streaming/GpuWorldState.cs:
using System.Collections.Generic;
using System.Linq;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <summary>
/// Render-thread-owned registry of currently-loaded landblocks and their
/// entities. All mutation happens in <see cref="StreamingController.Tick"/>
/// on the render thread; the renderer reads <see cref="Entities"/> once per
/// frame.
///
/// <para>
/// Replaces <c>GameWindow._entities</c>, which was a flat list updated in
/// multiple places. This class is the single point of truth for "what's in
/// the world right now" and the only thing that mutates it.
/// </para>
/// </summary>
public sealed class GpuWorldState
{
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
// Cached flat view over all entities across all loaded landblocks,
// rebuilt on each add/remove. The renderer holds a reference to this
// list, so rebuilding it replaces the reference atomically.
private IReadOnlyList<WorldEntity> _flatEntities = System.Array.Empty<WorldEntity>();
public IReadOnlyList<WorldEntity> Entities => _flatEntities;
public IReadOnlyCollection<uint> LoadedLandblockIds => _loaded.Keys;
public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
public void AddLandblock(LoadedLandblock landblock)
{
_loaded[landblock.Id] = landblock;
RebuildFlatView();
}
public void RemoveLandblock(uint landblockId)
{
if (_loaded.Remove(landblockId))
RebuildFlatView();
}
/// <summary>
/// Append an entity to a specific landblock's slot. Used by the live
/// CreateObject path where the server spawns entities into an already-
/// loaded landblock after the initial hydration pass.
/// </summary>
public void AppendLiveEntity(uint landblockId, WorldEntity entity)
{
if (!_loaded.TryGetValue(landblockId, out var lb))
return;
// LoadedLandblock.Entities is an IReadOnlyList. Rebuild the
// landblock record with the new entity appended. We accept the
// allocation here because live spawns are rare compared to frame
// iteration.
var newEntities = new List<WorldEntity>(lb.Entities.Count + 1);
newEntities.AddRange(lb.Entities);
newEntities.Add(entity);
_loaded[landblockId] = new LoadedLandblock(lb.Id, lb.Block, newEntities);
RebuildFlatView();
}
private void RebuildFlatView()
{
_flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray();
}
}
- Step 2: Build + commit (no unit test — trivial mutation of a dict)
Run: dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
Expected: build succeeds.
git add src/AcDream.App/Streaming/GpuWorldState.cs
git commit -m "feat(app): Phase A.1 — GpuWorldState render-thread entity registry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
Task 6: StreamingController (glue)
Files:
- Create:
src/AcDream.App/Streaming/StreamingController.cs - Test:
tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs
The controller is called once per frame from OnUpdate. It:
- Converts the current observer position (camera or player) into landblock coordinates
- Asks
StreamingRegionfor the diff since the last center - Enqueues the diff as jobs to
LandblockStreamer - Drains completions into
GpuWorldState(using a caller-supplied terrain apply callback for the GL upload)
Because Tick interacts with three collaborators, tests use fakes for all three.
- Step 1: Write the failing test — first Tick loads everything
Create tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs:
using System.Collections.Generic;
using AcDream.App.Streaming;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using Xunit;
namespace AcDream.Core.Tests.Streaming;
public class StreamingControllerTests
{
private sealed class FakeStreamer
{
public List<uint> Loads { get; } = new();
public List<uint> Unloads { get; } = new();
public Queue<LandblockStreamResult> Pending { get; } = new();
public void EnqueueLoad(uint id) => Loads.Add(id);
public void EnqueueUnload(uint id) => Unloads.Add(id);
public IReadOnlyList<LandblockStreamResult> DrainCompletions(int max)
{
var batch = new List<LandblockStreamResult>();
while (batch.Count < max && Pending.Count > 0)
batch.Add(Pending.Dequeue());
return batch;
}
}
[Fact]
public void FirstTick_EnqueuesWholeVisibleWindow()
{
var state = new GpuWorldState();
var fake = new FakeStreamer();
var controller = new StreamingController(
enqueueLoad: fake.EnqueueLoad,
enqueueUnload: fake.EnqueueUnload,
drainCompletions: fake.DrainCompletions,
applyTerrain: _ => { },
state: state,
radius: 2);
// Center at (50, 50); no landblocks loaded yet.
controller.Tick(observerCx: 50, observerCy: 50);
// 5×5 window = 25 loads enqueued, 0 unloads.
Assert.Equal(25, fake.Loads.Count);
Assert.Empty(fake.Unloads);
}
}
- Step 2: Run — verify failure
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~StreamingControllerTests"
Expected: build error StreamingController not found.
- Step 3: Create the controller
Create src/AcDream.App/Streaming/StreamingController.cs:
using System;
using System.Collections.Generic;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <summary>
/// Called once per frame from <c>GameWindow.OnUpdate</c>. Owns the
/// <see cref="StreamingRegion"/> and uses delegates into
/// <see cref="LandblockStreamer"/> so tests can inject fakes. All work
/// happens on the render thread; the streamer itself is background.
/// </summary>
public sealed class StreamingController
{
private readonly Action<uint> _enqueueLoad;
private readonly Action<uint> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LoadedLandblock> _applyTerrain;
private readonly GpuWorldState _state;
private StreamingRegion? _region;
public int Radius { get; set; }
public int MaxCompletionsPerFrame { get; set; } = 4;
public StreamingController(
Action<uint> enqueueLoad,
Action<uint> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
Action<LoadedLandblock> applyTerrain,
GpuWorldState state,
int radius)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
_applyTerrain = applyTerrain;
_state = state;
Radius = radius;
}
/// <summary>
/// Advance one frame. <paramref name="observerCx"/>/<paramref name="observerCy"/>
/// are landblock coordinates (0..255) of the current viewer — the camera
/// in offline mode, the server-sent player position in live.
/// </summary>
public void Tick(int observerCx, int observerCy)
{
// First-tick bootstrap: no region yet, so the whole visible window
// is a load diff.
if (_region is null)
{
_region = new StreamingRegion(observerCx, observerCy, Radius);
foreach (var id in _region.Visible)
_enqueueLoad(id);
}
else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
{
var diff = _region.RecenterTo(observerCx, observerCy);
foreach (var id in diff.ToLoad) _enqueueLoad(id);
foreach (var id in diff.ToUnload) _enqueueUnload(id);
}
// Drain up to N completions per frame so a big diff doesn't spike
// GPU upload time. Remaining completions wait for the next frame.
var drained = _drainCompletions(MaxCompletionsPerFrame);
foreach (var result in drained)
{
switch (result)
{
case LandblockStreamResult.Loaded loaded:
_applyTerrain(loaded.Landblock);
_state.AddLandblock(loaded.Landblock);
break;
case LandblockStreamResult.Unloaded unloaded:
_state.RemoveLandblock(unloaded.LandblockId);
break;
case LandblockStreamResult.Failed failed:
Console.WriteLine(
$"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}");
break;
}
}
}
}
- Step 4: Run test — verify pass
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~StreamingControllerTests"
Expected: PASS 1/1.
- Step 5: Add test — second Tick at same center is a no-op
Append to StreamingControllerTests.cs:
[Fact]
public void SecondTick_SamePosition_EnqueuesNothing()
{
var state = new GpuWorldState();
var fake = new FakeStreamer();
var controller = new StreamingController(
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
_ => { }, state, radius: 2);
controller.Tick(50, 50);
fake.Loads.Clear();
controller.Tick(50, 50);
Assert.Empty(fake.Loads);
Assert.Empty(fake.Unloads);
}
[Fact]
public void DrainingLoadedResult_AddsToState()
{
var state = new GpuWorldState();
var fake = new FakeStreamer();
var applied = new List<LoadedLandblock>();
var controller = new StreamingController(
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
applied.Add, state, radius: 2);
var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty<WorldEntity>());
fake.Pending.Enqueue(new LandblockStreamResult.Loaded(0x32320FFEu, lb));
controller.Tick(50, 50);
Assert.Single(applied);
Assert.True(state.IsLoaded(0x32320FFEu));
}
[Fact]
public void DrainingUnloadedResult_RemovesFromState()
{
var state = new GpuWorldState();
var fake = new FakeStreamer();
var controller = new StreamingController(
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
_ => { }, state, radius: 2);
var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty<WorldEntity>());
state.AddLandblock(lb);
fake.Pending.Enqueue(new LandblockStreamResult.Unloaded(0x32320FFEu));
controller.Tick(50, 50);
Assert.False(state.IsLoaded(0x32320FFEu));
}
- Step 6: Run — verify all 4 pass
Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~StreamingControllerTests"
Expected: PASS 4/4.
- Step 7: Commit
git add src/AcDream.App/Streaming/StreamingController.cs tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs
git commit -m "$(cat <<'EOF'
feat(app): Phase A.1 — StreamingController glue
Called once per frame from OnUpdate. Owns a StreamingRegion and uses
delegates into LandblockStreamer + a terrain-apply callback so unit
tests can inject fakes. Handles first-tick bootstrap (whole window
loads), boundary recenter (diff against previous center), and
drain completions (up to N per frame to cap GPU upload spikes).
4 new tests, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)"
Task 7: GameWindow wiring
Files:
- Modify:
src/AcDream.App/Rendering/GameWindow.cs
Replace the one-shot 3×3 preload at startup with streaming controller wiring. The old preload code is deleted; _entities is replaced by reads from GpuWorldState.Entities; live CreateObject handlers use GpuWorldState.AppendLiveEntity; OnUpdate calls StreamingController.Tick each frame.
This is the largest task because it touches GameWindow.cs in several places. Break it into sub-steps.
- Step 1: Add fields + env var parsing + controller construction
Near the top of GameWindow.cs where _terrain, _staticMesh, and _entities are declared, add:
private AcDream.App.Streaming.LandblockStreamer? _streamer;
private AcDream.App.Streaming.GpuWorldState _worldState = new();
private AcDream.App.Streaming.StreamingController? _streamingController;
private int _streamingRadius = 2; // default 5×5
Remove the declaration private IReadOnlyList<AcDream.Core.World.WorldEntity> _entities = Array.Empty<...>(); — its replacement is _worldState.Entities.
Update every read of _entities in this file to use _worldState.Entities. Use grep -n "_entities" src/AcDream.App/Rendering/GameWindow.cs to find every site, and replace each read-only reference. Mutating _entities (the current live-spawn path does var extended = new List<>(_entities) { entity }; _entities = extended;) is replaced by _worldState.AppendLiveEntity(landblockId, entity) — see Step 4 below.
- Step 2: Replace the one-shot preload with streamer construction
Locate the existing preload block in OnLoad (grep for WorldView.Load and hydratedEntities). Everything inside the preload block (terrain AddLandblock calls, scenery generator, interior walker, _entities = hydratedEntities) should be replaced with:
// Parse runtime radius (default 2 → 5×5 visible window) from the
// environment. Values outside [0, 8] are clamped to stay within
// sane memory bounds.
var radiusEnv = Environment.GetEnvironmentVariable("ACDREAM_STREAM_RADIUS");
if (int.TryParse(radiusEnv, out var r) && r >= 0 && r <= 8)
_streamingRadius = r;
Console.WriteLine($"streaming: radius={_streamingRadius} (window={2*_streamingRadius+1}×{2*_streamingRadius+1})");
// The streamer's load delegate wraps LandblockLoader.Load plus the
// scenery generator + EnvCell walker, producing a LoadedLandblock
// with all entities (stabs, scenery, interior) baked in. This is
// the only place that knows how to build a landblock; the streamer
// and controller stay dat-agnostic.
_streamer = new AcDream.App.Streaming.LandblockStreamer(
loadLandblock: id => BuildLandblockForStreaming(id));
_streamer.Start();
_streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: _streamer.EnqueueLoad,
enqueueUnload: _streamer.EnqueueUnload,
drainCompletions: _streamer.DrainCompletions,
applyTerrain: ApplyLoadedTerrain,
state: _worldState,
radius: _streamingRadius);
The centerLandblockId the old code used is now the first-frame observer center, not a one-shot preload. Store it so OnUpdate can supply it to Tick before any camera movement.
- Step 3: Add
BuildLandblockForStreamingandApplyLoadedTerrainhelpers
Add as private methods on GameWindow:
/// <summary>
/// Production implementation of the streamer's load delegate. Runs on
/// the worker thread — must be GPU-free. Loads the landblock + info
/// from the dats, runs the existing scenery generator + EnvCell
/// walker, and returns a fully-populated LoadedLandblock. The CPU
/// cost is bounded (per-landblock dat read + mesh build) and the
/// worker thread absorbs it.
/// </summary>
private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreaming(uint landblockId)
{
if (_dats is null) return null;
var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId);
if (baseLoaded is null) return null;
// Future: merge scenery + interior entities into baseLoaded here.
// For the minimum-viable Phase A.1 commit, we ship with stabs only
// — the scenery generator and EnvCell walker land in a follow-up
// commit in this same plan (see Step 6).
return baseLoaded;
}
/// <summary>
/// Called on the render thread by StreamingController.Tick whenever a
/// new landblock's terrain is ready for GPU upload. Calls the existing
/// TerrainRenderer.AddLandblock path — same code as the old preload.
/// </summary>
private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb)
{
if (_terrain is null) return;
// Rebuild the same per-landblock mesh data the old preload built.
// Compute world origin from the landblock coordinates relative to
// the configured streaming center (stored in _liveCenterX/Y).
int lbX = (int)((lb.Id >> 24) & 0xFFu);
int lbY = (int)((lb.Id >> 16) & 0xFFu);
var origin = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
0f);
// Existing mesh builder — unchanged from the old preload. If
// LandblockMesh.Build is in a different location, adjust the
// fully-qualified name.
var meshData = AcDream.Core.Terrain.LandblockMesh.Build(lb.Block, _terrainAtlas!);
_terrain.AddLandblock(lb.Id, meshData, origin);
// Similarly upload entity GfxObjs on the render thread. The old
// preload iterated hydratedEntities and called _staticMesh.EnsureUploaded
// for each distinct GfxObjId referenced by a MeshRef.
if (_staticMesh is not null && _dats is not null)
{
foreach (var entity in lb.Entities)
{
foreach (var meshRef in entity.MeshRefs)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(meshRef.GfxObjId);
if (gfx is null) continue;
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
_staticMesh.EnsureUploaded(meshRef.GfxObjId, subMeshes);
}
}
}
}
Note: TerrainRenderer.AddLandblock now takes a uint landblockId as its first parameter — see Task 4. If you haven't yet updated AddLandblock's signature there, do it now (adding the landblockId parameter and keying the internal collection by it) as part of this task's commit.
- Step 4: Drive Tick from OnUpdate
Locate OnUpdate in GameWindow.cs and, immediately after _liveSession?.Tick();, add:
// Compute the current observer landblock coordinates. Offline
// mode follows the fly camera; live mode follows the player's
// server-side position once we've received it. Default to the
// configured center for the first frame before either source
// produces a position.
if (_streamingController is not null && _cameraController is not null)
{
int observerCx = _liveCenterX;
int observerCy = _liveCenterY;
if (_liveSession?.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld
&& _lastLivePlayerLandblockId is { } lid)
{
observerCx = (int)((lid >> 24) & 0xFFu);
observerCy = (int)((lid >> 16) & 0xFFu);
}
else
{
// Offline: project the camera's world-space position back
// into landblock coordinates via the same offset math
// ApplyLoadedTerrain uses.
var cam = _cameraController.Active.Position;
observerCx = _liveCenterX + (int)System.Math.Floor(cam.X / 192f);
observerCy = _liveCenterY + (int)System.Math.Floor(cam.Y / 192f);
}
_streamingController.Tick(observerCx, observerCy);
}
Add a field private uint? _lastLivePlayerLandblockId; near the other live-mode fields, and in the existing OnLivePositionUpdated handler, add (if the position update is for our own character) _lastLivePlayerLandblockId = update.Position.LandblockId;.
- Step 5: Update live-spawn code to use
AppendLiveEntity
Locate the existing OnLiveEntitySpawned handler and find the code that currently does:
var extended = new List<AcDream.Core.World.WorldEntity>(_entities) { entity };
_entities = extended;
Replace with:
_worldState.AppendLiveEntity(spawn.Position!.Value.LandblockId, entity);
The server-sent position is already available as spawn.Position. The spawn is guarded above by if (spawn.Position is null || spawn.SetupTableId is null) return; so the !. is safe.
- Step 6: Drive the renderer off
_worldState.Entities
Locate OnRender and the call to _staticMesh?.Draw(_cameraController.Active, _entities);. Replace the _entities parameter with _worldState.Entities:
_staticMesh?.Draw(_cameraController.Active, _worldState.Entities);
Same for the animation tick in TickAnimations — it iterates animated entities via a guid dictionary that's already separate from _entities, so no change there. But if TickAnimations reads from _entities directly, replace with _worldState.Entities.
- Step 7: Ensure
Disposetears down the streamer
Locate OnClosing and add near the existing _liveSession?.Dispose();:
_streamer?.Dispose();
Before any GL resource disposal. The streamer's worker thread must finish before we tear down GL state.
- Step 8: Build
Run: cmd.exe /c "taskkill /F /IM AcDream.App.exe 2>nul" && dotnet build -c Debug
Expected: build succeeds (0 errors, 0 warnings is ideal; resolve any new warnings before continuing).
- Step 9: Run all tests
Run: dotnet test -c Debug --nologo
Expected: no regressions. All existing tests plus the ~16 new streaming tests pass.
- Step 10: Manual live-run smoke (critical — this is the visual acceptance checkpoint)
Run the app:
ACDREAM_DAT_DIR="C:/Turbine/Asheron's Call" ACDREAM_LIVE=1 \
ACDREAM_TEST_USER=testaccount ACDREAM_TEST_PASS=testpassword \
dotnet run --project src/AcDream.App -c Debug
Check:
- Startup log contains
streaming: radius=2 (window=5×5). - Login to Holtburg succeeds; terrain and entities render as before.
- Fly the camera 3+ landblocks in any direction — new terrain appears at the leading edge, old terrain disappears at the trailing edge.
- No crashes, no black voids, no missing entities in the new visible area.
- Close the window; clean shutdown with no exceptions.
If visual acceptance fails, diagnose and loop back — do not commit broken state.
- Step 11: Commit
git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/Rendering/TerrainRenderer.cs
git commit -m "$(cat <<'EOF'
feat(app): Phase A.1 — wire streaming controller into GameWindow
Replaces the one-shot 3×3 preload in OnLoad with StreamingController +
LandblockStreamer. Runtime-configurable window radius via
ACDREAM_STREAM_RADIUS (default 2 → 5×5). OnUpdate drives
StreamingController.Tick once per frame with the current observer
coordinates (camera-offset in offline, player-sent in live).
_entities flat list replaced by GpuWorldState.Entities. Live
CreateObject handler uses GpuWorldState.AppendLiveEntity instead of
list rebuild-and-replace. TerrainRenderer.AddLandblock takes a
landblock id key so RemoveLandblock can find and free it.
Streamer is disposed in OnClosing before GL teardown so the worker
thread is joined before we release resources.
Visually verified: walk across 5+ landblocks, no crashes, no voids.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)"
Task 8: Scenery + interior entities in streamed loads
Files:
- Modify:
src/AcDream.App/Rendering/GameWindow.cs
Task 7 shipped with a minimum-viable BuildLandblockForStreaming that only returns LandblockLoader.Load's output (stabs). The old preload additionally ran a scenery generator and an EnvCell interior walker. Port both into BuildLandblockForStreaming so streamed landblocks get the full entity set.
- Step 1: Locate the scenery + EnvCell code in the old preload
Run: grep -n "SceneryGenerator\|EnvCell\|interiorSpawned" src/AcDream.App/Rendering/GameWindow.cs
Note the line ranges for both blocks.
- Step 2: Extract both into helper methods that take
(DatCollection, uint, LoadedLandblock)and return an extended entity list
Create two private methods next to BuildLandblockForStreaming:
private IReadOnlyList<AcDream.Core.World.WorldEntity> BuildSceneryEntities(
AcDream.Core.World.LoadedLandblock lb)
{
// Port the body of the old scenery generation loop here. It takes
// a LoadedLandblock and uses its heightmap to produce scenery
// entities via SceneryGenerator.Generate. Return an empty list if
// SceneryGenerator is not applicable.
return AcDream.Core.World.SceneryGenerator.Generate(lb);
}
private IReadOnlyList<AcDream.Core.World.WorldEntity> BuildInteriorEntities(
uint landblockId)
{
// Port the body of the EnvCell interior walker loop here. It takes
// a landblock id and walks EnvCell 0xAAAA0100+N, appending each
// cell's StaticObjects as world entities plus the cell-mesh
// entity (from Phase 7.1).
// ... existing walker body, adjusted to return a list ...
return new List<AcDream.Core.World.WorldEntity>(); // placeholder
}
The exact code for both methods is lifted from the existing preload block in OnLoad — preserve all the logic (including the cell mesh Z-lift and the interiorIdCounter seeding) exactly.
- Step 3: Update
BuildLandblockForStreamingto merge all three sources
Replace the body with:
private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreaming(uint landblockId)
{
if (_dats is null) return null;
var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId);
if (baseLoaded is null) return null;
var merged = new List<AcDream.Core.World.WorldEntity>(baseLoaded.Entities);
merged.AddRange(BuildSceneryEntities(baseLoaded));
merged.AddRange(BuildInteriorEntities(landblockId));
return new AcDream.Core.World.LoadedLandblock(
baseLoaded.Id,
baseLoaded.Block,
merged);
}
- Step 4: Delete the old inline preload blocks in
OnLoad
Remove the two old loops (scenery + interior) from OnLoad. They're now called by the streamer via BuildLandblockForStreaming. The build should already pass because OnLoad no longer references the removed code.
- Step 5: Build
Run: cmd.exe /c "taskkill /F /IM AcDream.App.exe 2>nul" && dotnet build -c Debug
Expected: 0 errors.
- Step 6: Run all tests
Run: dotnet test -c Debug --nologo
Expected: no regressions.
- Step 7: Manual live-run — verify scenery + interior still show up
Run the app (same command as Task 7 step 10). Check:
- Trees, rocks, bushes visible in the 5×5 window.
- Building interiors (walls, floors, ceilings) visible when you fly inside.
- Furniture / static interior objects visible.
If anything is missing, the scenery or interior extraction likely dropped a call — diff against the original preload code.
- Step 8: Commit
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
feat(app): Phase A.1 — include scenery + interior entities in streamed loads
Task 7 shipped the streaming loader with stabs only; this wires
BuildLandblockForStreaming to also run the SceneryGenerator
(trees/rocks/bushes) and the EnvCell interior walker (static objects
+ cell-mesh room geometry). Every streamed landblock now carries the
full entity set the old one-shot preload produced.
Deletes the old inline scenery + interior loops in OnLoad now that
BuildLandblockForStreaming owns that work.
Visually verified: scenery + interior meshes appear in every landblock
the camera enters.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)"
Task 9: Update roadmap "shipped" table
Files:
- Modify:
docs/plans/2026-04-11-roadmap.md
Per the CLAUDE.md roadmap discipline rule: when a phase (or sub-piece) ships, move it from "ahead" to "shipped" in the same commit or the immediately following one.
- Step 1: Move Phase A.1 into the shipped table
Open docs/plans/2026-04-11-roadmap.md and:
- In the
## Phases already shippedtable, add a new row immediately after the9.2row:
| A.1 | Streaming landblock loader — runtime-configurable window, background worker, hysteresis-based unloads | Visual ✓ |
- In the
### Phase A — Foundation (next)section, prepend**✓ SHIPPED**to theA.1bullet:
- **✓ SHIPPED — A.1 — Streaming landblock loader.** Runtime-configurable visible window (default 5×5, `ACDREAM_STREAM_RADIUS` env var override)...
- In the quick-lookup table at the bottom, update:
| Can't walk past the loaded 3×3 window | **A.1 FIXED** ✓ |
| Frame hitch crossing landblock boundary | **A.1 FIXED** ✓ |
- Step 2: Commit
git add docs/plans/2026-04-11-roadmap.md
git commit -m "docs: mark Phase A.1 (streaming) as shipped in roadmap
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
Self-review
After writing all 9 tasks, I checked the plan against the spec section-by-section:
Spec coverage:
- StreamingRegion — Task 1 ✓
- LandblockStreamer + job / result records — Task 2 + 3 ✓
- GpuWorldState — Task 5 ✓
- StreamingController — Task 6 ✓
- GameWindow wiring + TerrainRenderer.RemoveLandblock — Tasks 4, 7, 8 ✓
ACDREAM_STREAM_RADIUSenv var — Task 7 step 2 ✓- Camera/player center switching — Task 7 step 4 ✓
- Hysteresis — Task 1 step 9 ✓
- Error handling for failed loads — Task 6 controller swallows Failed results with console log ✓
- Worker shutdown — Task 3 Dispose + Task 7 step 7 ✓
- Roadmap update — Task 9 ✓
- Frustum culling + net I/O thread are intentionally out of scope (separate plans for A.2 and A.3).
Placeholder scan: one spot where Task 8 step 2 says "The exact code for both methods is lifted from the existing preload block" without showing the lift. This is acceptable because the engineer has the source file open and the referenced code is already in GameWindow.cs; pasting it here would be a misleading static copy. The instruction "preserve all the logic exactly" is specific enough. Task 4 step 2 similarly defers the body to reading AddLandblock first, which is appropriate when the goal is "mirror the existing pattern in reverse."
Type consistency: StreamingRegion.RecenterTo returns RegionDiff, used in StreamingController.Tick. LandblockStreamer.DrainCompletions(int) returns IReadOnlyList<LandblockStreamResult>, matched by StreamingController's drainCompletions delegate. GpuWorldState.AppendLiveEntity(uint, WorldEntity) called in Task 7 step 5. All consistent.
Execution Handoff
Plan complete and saved to docs/superpowers/plans/2026-04-11-foundation-a1-streaming.md. Two execution options:
1. Subagent-Driven (recommended) — I dispatch a fresh Sonnet subagent per task (per the new Subagent policy in CLAUDE.md), review between tasks, fast iteration. Best for this plan because the tasks are well-bounded and TDD discipline benefits from fresh context per task.
2. Inline Execution — Execute tasks in this session using executing-plans, batch execution with checkpoints for review.
Which approach?