From b8d80fe2823c7515b92ab1e541a449c32a7bc401 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 07:56:57 +0200 Subject: [PATCH] feat(A.5 T13): StreamingController two-tier Tick Replaces the single-radius Tick with a two-tier model that consumes StreamingRegion's TwoTierDiff (5-list) and routes to the appropriate JobKind: - ToLoadFar -> _enqueueLoad(id, LoadFar) - ToLoadNear -> _enqueueLoad(id, LoadNear) - ToPromote -> _enqueueLoad(id, PromoteToNear) - ToDemote -> _state.RemoveEntitiesFromLandblock(id) on render thread - ToUnload -> _enqueueUnload(id) Drain switch handles Loaded (terrain + entity layer), Promoted (entity layer only -- terrain already loaded), Unloaded, Failed, WorkerCrashed. Constructor signature: nearRadius/farRadius separate ints. Old single- radius ctor removed; existing single-radius tests updated to pass nearRadius=farRadius for backward-compat coverage. GameWindow's enqueueLoad lambda updated from (id =>...) to (id, kind) => to match new Action signature; radius: arg renamed to nearRadius:/farRadius: (both set to _streamingRadius until T16 wires the full two-tier env-var parsing). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 7 ++- .../Streaming/StreamingController.cs | 50 ++++++++++++------- .../Streaming/StreamingControllerTests.cs | 13 ++--- .../StreamingControllerTwoTierTests.cs | 38 ++++++++++++++ 4 files changed, 79 insertions(+), 29 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a5e5a69..81f6560 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1605,14 +1605,13 @@ public sealed class GameWindow : IDisposable _streamer.Start(); _streamingController = new AcDream.App.Streaming.StreamingController( - // Use a lambda so the Action delegate matches the method - // signature (EnqueueLoad has an optional 'kind' parameter). - enqueueLoad: id => _streamer.EnqueueLoad(id, AcDream.App.Streaming.LandblockStreamJobKind.LoadNear), + enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind), enqueueUnload: _streamer.EnqueueUnload, drainCompletions: _streamer.DrainCompletions, applyTerrain: ApplyLoadedTerrain, state: _worldState, - radius: _streamingRadius, + nearRadius: _streamingRadius, + farRadius: _streamingRadius, removeTerrain: id => { // Phase G.2: release any LightSources attached to entities diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 61cd5b8..a9a8864 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -17,7 +17,7 @@ namespace AcDream.App.Streaming; /// public sealed class StreamingController { - private readonly Action _enqueueLoad; + private readonly Action _enqueueLoad; private readonly Action _enqueueUnload; private readonly Func> _drainCompletions; private readonly Action _applyTerrain; @@ -25,7 +25,8 @@ public sealed class StreamingController private readonly GpuWorldState _state; private StreamingRegion? _region; - public int Radius { get; set; } + public int NearRadius { get; set; } + public int FarRadius { get; set; } /// /// Cap on completions drained per call. The cap is @@ -46,12 +47,13 @@ public sealed class StreamingController public int MaxCompletionsPerFrame { get; set; } = 4; public StreamingController( - Action enqueueLoad, + Action enqueueLoad, Action enqueueUnload, Func> drainCompletions, Action applyTerrain, GpuWorldState state, - int radius, + int nearRadius, + int farRadius, Action? removeTerrain = null) { _enqueueLoad = enqueueLoad; @@ -60,29 +62,42 @@ public sealed class StreamingController _applyTerrain = applyTerrain; _removeTerrain = removeTerrain; _state = state; - Radius = radius; + NearRadius = nearRadius; + FarRadius = farRadius; } /// /// Advance one frame. / /// are landblock coordinates (0..255) of the current viewer — the camera /// in offline mode, the server-sent player position in live. + /// + /// Two-tier model (Phase A.5 T13): + /// + /// → enqueue LoadFar (terrain only, no entities) + /// → enqueue LoadNear (terrain + entities) + /// → enqueue PromoteToNear (entity layer for already-loaded terrain) + /// → drop entities on render thread immediately (terrain stays) + /// → enqueue full unload + /// /// 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); + _region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius); + var bootstrap = _region.ComputeFirstTickDiff(); + foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar); + foreach (var id in bootstrap.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear); + _region.MarkResidentFromBootstrap(); } else if (_region.CenterX != observerCx || _region.CenterY != observerCy) { - var diff = _region.RecenterToSingleTier(observerCx, observerCy); - foreach (var id in diff.ToLoad) _enqueueLoad(id); - foreach (var id in diff.ToUnload) _enqueueUnload(id); + var diff = _region.RecenterTo(observerCx, observerCy); + foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar); + foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear); + foreach (var id in diff.ToPromote) _enqueueLoad(id, LandblockStreamJobKind.PromoteToNear); + foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id); + foreach (var id in diff.ToUnload) _enqueueUnload(id); } // Drain up to N completions per frame so a big diff doesn't spike @@ -96,6 +111,9 @@ public sealed class StreamingController _applyTerrain(loaded.Landblock, loaded.MeshData); _state.AddLandblock(loaded.Landblock); break; + case LandblockStreamResult.Promoted promoted: + _state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities); + break; case LandblockStreamResult.Unloaded unloaded: _state.RemoveLandblock(unloaded.LandblockId); _removeTerrain?.Invoke(unloaded.LandblockId); @@ -108,12 +126,6 @@ public sealed class StreamingController Console.WriteLine( $"streaming: worker CRASHED: {crashed.Error}"); break; - case LandblockStreamResult.Promoted: - // TODO(A.5 T13): merge promoted entities into existing - // GpuWorldState entry via AddEntitiesToExistingLandblock. - // Today the streamer never produces Promoted (only LoadNear / - // LoadFar), so this arm is unreachable and silently consumed. - break; } } } diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs index cb79116..3364d77 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs @@ -14,7 +14,7 @@ public class StreamingControllerTests public List Unloads { get; } = new(); public Queue Pending { get; } = new(); - public void EnqueueLoad(uint id) => Loads.Add(id); + public void EnqueueLoad(uint id, LandblockStreamJobKind _) => Loads.Add(id); public void EnqueueUnload(uint id) => Unloads.Add(id); public IReadOnlyList DrainCompletions(int max) { @@ -36,12 +36,13 @@ public class StreamingControllerTests drainCompletions: fake.DrainCompletions, applyTerrain: (_, _) => { }, state: state, - radius: 2); + nearRadius: 2, + farRadius: 2); // Center at (50, 50); no landblocks loaded yet. controller.Tick(observerCx: 50, observerCy: 50); - // 5×5 window = 25 loads enqueued, 0 unloads. + // 5×5 window = 25 loads enqueued (nearRadius==farRadius so all go to ToLoadNear), 0 unloads. Assert.Equal(25, fake.Loads.Count); Assert.Empty(fake.Unloads); } @@ -53,7 +54,7 @@ public class StreamingControllerTests var fake = new FakeStreamer(); var controller = new StreamingController( fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, - (_, _) => { }, state, radius: 2); + (_, _) => { }, state, nearRadius: 2, farRadius: 2); controller.Tick(50, 50); fake.Loads.Clear(); @@ -72,7 +73,7 @@ public class StreamingControllerTests var applied = new List(); var controller = new StreamingController( fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, - (lb, _) => applied.Add(lb), state, radius: 2); + (lb, _) => applied.Add(lb), state, nearRadius: 2, farRadius: 2); // Note: LoadedLandblock's actual fields are LandblockId, Heightmap, // Entities (positional record). Adjust if the first positional arg @@ -99,7 +100,7 @@ public class StreamingControllerTests var fake = new FakeStreamer(); var controller = new StreamingController( fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, - (_, _) => { }, state, radius: 2); + (_, _) => { }, state, nearRadius: 2, farRadius: 2); var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty()); state.AddLandblock(lb); diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs new file mode 100644 index 0000000..bc18249 --- /dev/null +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using AcDream.App.Streaming; +using AcDream.Core.Terrain; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.Streaming; + +public class StreamingControllerTwoTierTests +{ + [Fact] + public void Tick_FirstCall_EnqueuesNearAndFarLoadsByTier() + { + var loads = new List<(uint Id, LandblockStreamJobKind Kind)>(); + var unloads = new List(); + var state = new GpuWorldState(); + + var ctrl = new StreamingController( + enqueueLoad: (id, kind) => loads.Add((id, kind)), + enqueueUnload: unloads.Add, + drainCompletions: _ => System.Array.Empty(), + applyTerrain: (_, _) => { }, + state: state, + nearRadius: 1, + farRadius: 3); + + ctrl.Tick(observerCx: 100, observerCy: 100); + + int nearCount = 0, farCount = 0; + foreach (var (_, kind) in loads) + { + if (kind == LandblockStreamJobKind.LoadNear) nearCount++; + else if (kind == LandblockStreamJobKind.LoadFar) farCount++; + } + Assert.Equal(9, nearCount); // 3x3 inner ring (radius=1) + Assert.Equal(40, farCount); // 7x7 - 3x3 outer ring (radius=3) + } +}