diff --git a/docs/superpowers/plans/2026-04-11-foundation-a1-streaming.md b/docs/superpowers/plans/2026-04-11-foundation-a1-streaming.md new file mode 100644 index 0000000..59ed9f2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-foundation-a1-streaming.md @@ -0,0 +1,1600 @@ +# 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`; 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 + 1` 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: +- `StreamingRegion` is a pure value type with no dependencies — easy TDD. +- `LandblockStreamer` takes a `Func` load delegate instead of a `DatCollection`, so tests can inject a fake without touching the dat subsystem. +- `GpuWorldState` owns mutable per-landblock GPU-side state but is driven entirely by the render thread — no locking needed. +- `StreamingController` is the glue. Tests inject a fake `LandblockStreamer` implementation 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 `` 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 `` containing other `` entries: + +```xml + +``` + +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`: + +```csharp +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 `StreamingRegion` with minimal API** + +Create `src/AcDream.App/Streaming/StreamingRegion.cs`: + +```csharp +using System.Collections.Generic; + +namespace AcDream.App.Streaming; + +/// +/// 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. +/// +public sealed class StreamingRegion +{ + public int CenterX { get; private set; } + public int CenterY { get; private set; } + public int Radius { get; } + + private readonly HashSet _visible = new(); + + /// + /// Landblock IDs (8.8 coordinate form: (lbX << 24) | (lbY << 16) | 0xFFFE) + /// in the current visible window. + /// + public IReadOnlyCollection 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`: + +```csharp + [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`: + +```csharp + [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+1 = 3. + // 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 equals the + // threshold, so it stays loaded (hysteresis keeps radius+1). + 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+1 → 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 `RegionDiff` record and `RecenterTo` method** + +Append to `StreamingRegion.cs` (inside the `AcDream.App.Streaming` namespace): + +```csharp +/// +/// Output of : the landblocks to +/// start loading (newly entered the visible window) and the landblocks to +/// unload (fell outside the unload threshold, which is radius + 1). +/// Both lists are disjoint from the current +/// set; the caller hands them to LandblockStreamer as jobs. +/// +public readonly record struct RegionDiff( + IReadOnlyList ToLoad, + IReadOnlyList ToUnload); +``` + +Add to the `StreamingRegion` class body (below `Recenter`): + +```csharp + /// + /// 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 Radius + 1 from the new center, + /// so boundary crossings don't thrash. + /// + public RegionDiff RecenterTo(int newCx, int newCy) + { + // Snapshot the current visible set so we can diff against it. + var oldVisible = new HashSet(_visible); + Recenter(newCx, newCy); + + // Loads = everything newly in visible but not previously. + var toLoad = new List(); + 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 + 1; + var toUnload = new List(); + 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** + +```bash +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+1 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) +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`: + +```csharp +using AcDream.Core.World; + +namespace AcDream.App.Streaming; + +/// +/// A job posted to '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). +/// +public abstract record LandblockStreamJob(uint LandblockId) +{ + public sealed record Load(uint LandblockId) : LandblockStreamJob(LandblockId); + public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId); +} + +/// +/// 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). +/// +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** + +```bash +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) " +``` + +--- + +## 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` 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`: + +```csharp +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()); + + 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(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`: + +```csharp +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; + +/// +/// 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 +/// ); completed results are posted to +/// an outbox channel the render thread drains once per OnUpdate. +/// +/// +/// Unloads are passed through the same channel as a +/// record so the render thread can release GPU state on the next drain — +/// the worker never touches GPU resources directly. +/// +/// +public sealed class LandblockStreamer : IDisposable +{ + private readonly Func _loadLandblock; + private readonly Channel _inbox; + private readonly Channel _outbox; + private readonly CancellationTokenSource _cancel = new(); + private Thread? _worker; + private bool _disposed; + + public LandblockStreamer(Func loadLandblock) + { + _loadLandblock = loadLandblock; + _inbox = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + _outbox = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); + } + + /// + /// Start the worker thread. Must be called before enqueueing jobs. + /// Calling twice is a no-op. + /// + 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)); + } + + /// + /// Drain up to completed results. + /// Non-blocking. Call from the render thread once per OnUpdate. + /// + public IReadOnlyList DrainCompletions(int maxBatchSize = 4) + { + var batch = new List(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`: + +```csharp + [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(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(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(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** + +```bash +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 (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) +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`). + +- [ ] **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: + +```csharp + /// + /// 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. + /// + 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` 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** + +```bash +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) " +``` + +--- + +## 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` 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`: + +```csharp +using System.Collections.Generic; +using System.Linq; +using AcDream.Core.World; + +namespace AcDream.App.Streaming; + +/// +/// Render-thread-owned registry of currently-loaded landblocks and their +/// entities. All mutation happens in +/// on the render thread; the renderer reads once per +/// frame. +/// +/// +/// Replaces GameWindow._entities, 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. +/// +/// +public sealed class GpuWorldState +{ + private readonly Dictionary _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 _flatEntities = System.Array.Empty(); + + public IReadOnlyList Entities => _flatEntities; + public IReadOnlyCollection 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(); + } + + /// + /// 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. + /// + 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(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. + +```bash +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) " +``` + +--- + +## 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: +1. Converts the current observer position (camera or player) into landblock coordinates +2. Asks `StreamingRegion` for the diff since the last center +3. Enqueues the diff as jobs to `LandblockStreamer` +4. 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`: + +```csharp +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 Loads { get; } = new(); + public List Unloads { get; } = new(); + public Queue Pending { get; } = new(); + + public void EnqueueLoad(uint id) => Loads.Add(id); + public void EnqueueUnload(uint id) => Unloads.Add(id); + public IReadOnlyList DrainCompletions(int max) + { + var batch = new List(); + 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`: + +```csharp +using System; +using System.Collections.Generic; +using AcDream.Core.World; + +namespace AcDream.App.Streaming; + +/// +/// Called once per frame from GameWindow.OnUpdate. Owns the +/// and uses delegates into +/// so tests can inject fakes. All work +/// happens on the render thread; the streamer itself is background. +/// +public sealed class StreamingController +{ + private readonly Action _enqueueLoad; + private readonly Action _enqueueUnload; + private readonly Func> _drainCompletions; + private readonly Action _applyTerrain; + private readonly GpuWorldState _state; + private StreamingRegion? _region; + + public int Radius { get; set; } + public int MaxCompletionsPerFrame { get; set; } = 4; + + public StreamingController( + Action enqueueLoad, + Action enqueueUnload, + Func> drainCompletions, + Action applyTerrain, + GpuWorldState state, + int radius) + { + _enqueueLoad = enqueueLoad; + _enqueueUnload = enqueueUnload; + _drainCompletions = drainCompletions; + _applyTerrain = applyTerrain; + _state = state; + Radius = radius; + } + + /// + /// Advance one frame. / + /// are landblock coordinates (0..255) of the current viewer — the camera + /// in offline mode, the server-sent player position in live. + /// + 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`: + +```csharp + [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(); + 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()); + 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()); + 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** + +```bash +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) +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: + +```csharp + 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 _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: + +```csharp + // 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 `BuildLandblockForStreaming` and `ApplyLoadedTerrain` helpers** + +Add as private methods on `GameWindow`: + +```csharp + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + 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(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: + +```csharp + // 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: + +```csharp + var extended = new List(_entities) { entity }; + _entities = extended; +``` + +Replace with: + +```csharp + _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`: + +```csharp + _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 `Dispose` tears down the streamer** + +Locate `OnClosing` and add near the existing `_liveSession?.Dispose();`: + +```csharp + _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: +```bash +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: +1. Startup log contains `streaming: radius=2 (window=5×5)`. +2. Login to Holtburg succeeds; terrain and entities render as before. +3. Fly the camera 3+ landblocks in any direction — new terrain appears at the leading edge, old terrain disappears at the trailing edge. +4. No crashes, no black voids, no missing entities in the new visible area. +5. Close the window; clean shutdown with no exceptions. + +If visual acceptance fails, diagnose and loop back — do not commit broken state. + +- [ ] **Step 11: Commit** + +```bash +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) +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`: + +```csharp + private IReadOnlyList 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 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(); // 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 `BuildLandblockForStreaming` to merge all three sources** + +Replace the body with: + +```csharp + 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(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: +1. Trees, rocks, bushes visible in the 5×5 window. +2. Building interiors (walls, floors, ceilings) visible when you fly inside. +3. 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** + +```bash +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) +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: + +1. In the `## Phases already shipped` table, add a new row immediately after the `9.2` row: + +```markdown +| A.1 | Streaming landblock loader — runtime-configurable window, background worker, hysteresis-based unloads | Visual ✓ | +``` + +2. In the `### Phase A — Foundation (next)` section, prepend `**✓ SHIPPED**` to the `A.1` bullet: + +```markdown +- **✓ SHIPPED — A.1 — Streaming landblock loader.** Runtime-configurable visible window (default 5×5, `ACDREAM_STREAM_RADIUS` env var override)... +``` + +3. In the quick-lookup table at the bottom, update: + +```markdown +| Can't walk past the loaded 3×3 window | **A.1 FIXED** ✓ | +| Frame hitch crossing landblock boundary | **A.1 FIXED** ✓ | +``` + +- [ ] **Step 2: Commit** + +```bash +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) " +``` + +--- + +## 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_RADIUS` env 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`, 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?