diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs
new file mode 100644
index 0000000..c5e8050
--- /dev/null
+++ b/src/AcDream.App/Streaming/StreamingController.cs
@@ -0,0 +1,92 @@
+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.
+///
+///
+/// Threading: not thread-safe. All calls must happen on the render thread.
+///
+///
+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;
+ case LandblockStreamResult.WorkerCrashed crashed:
+ Console.WriteLine(
+ $"streaming: worker CRASHED: {crashed.Error}");
+ break;
+ }
+ }
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs
new file mode 100644
index 0000000..f7fa328
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs
@@ -0,0 +1,106 @@
+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);
+ }
+
+ [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);
+
+ // Note: LoadedLandblock's actual fields are LandblockId, Heightmap,
+ // Entities (positional record). Adjust if the first positional arg
+ // name differs.
+ 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));
+ }
+}