fix(app): Phase A.1 — separate Visible from Resident in StreamingRegion

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>
This commit is contained in:
Erik 2026-04-11 22:08:17 +02:00
parent 11df7930fc
commit 449c2caf8b
3 changed files with 67 additions and 29 deletions

View file

@ -4,7 +4,7 @@
**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 + 1` to prevent churn at boundary crossings.
**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.
@ -211,11 +211,11 @@ Append to `StreamingRegionTests.cs`:
[Fact]
public void RecenterTo_SingleStepEast_LoadsColumn_NoUnloadsDueToHysteresis()
{
// Radius 2 → unload threshold is radius+1 = 3.
// 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 equals the
// threshold, so it stays loaded (hysteresis keeps radius+1).
// 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);
@ -229,7 +229,7 @@ Append to `StreamingRegionTests.cs`:
{
// 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.
// 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);
@ -264,7 +264,7 @@ 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 + 1</c>).
/// 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>
@ -279,7 +279,7 @@ 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 + 1</c> from the new center,
/// 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)
@ -296,7 +296,7 @@ Add to the `StreamingRegion` class body (below `Recenter`):
// Unloads = everything previously visible AND now outside the
// hysteresis threshold (|dx| > r+1 OR |dy| > r+1).
int unloadThreshold = Radius + 1;
int unloadThreshold = Radius + 2;
var toUnload = new List<uint>();
foreach (var id in oldVisible)
{
@ -335,7 +335,7 @@ 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.
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.