From 9067c4f60b7413ac04851fb24be0ff6efb541078 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 22:26:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(app):=20Phase=20A.1=20=E2=80=94=20Streamin?= =?UTF-8?q?gController=20glue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Streaming/StreamingController.cs | 92 +++++++++++++++ .../Streaming/StreamingControllerTests.cs | 106 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 src/AcDream.App/Streaming/StreamingController.cs create mode 100644 tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs 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)); + } +}