diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index c7b139ee..30b690cc 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -89,6 +89,28 @@ public sealed class StreamingController /// public int MaxCompletionsPerFrame { get; set; } = 4; + /// + /// #138: the teleport destination landblock id. When non-zero, + /// applies this landblock's + /// or + /// completion immediately + /// — even if it sits past the + /// position in the outbox — so the player can materialise at the + /// destination without waiting for every earlier-queued landblock to + /// drain first. Non-priority completions drained past it are buffered + /// in and applied over subsequent frames, + /// so no completions are lost and there is no GPU spike. + /// + /// Set by GameWindow when a teleport starts; reset to 0 + /// once the destination landblock has been applied (or when the + /// teleport destination changes). + /// + public uint PriorityLandblockId { get; set; } + + // Completions that were drained past a priority item get buffered here + // so they still apply over subsequent frames without loss. + private readonly List _deferredApply = new(); + public StreamingController( Action enqueueLoad, Action enqueueUnload, @@ -299,40 +321,133 @@ public sealed class StreamingController /// /// Drain up to N completions per frame so a big diff doesn't spike GPU /// upload time. Remaining completions wait for the next frame. + /// + /// + /// When is set (non-zero), the priority + /// landblock is applied immediately even if it sits past position N in the + /// outbox. Non-priority completions drained past it are buffered in + /// and applied over subsequent frames + /// (no loss, no GPU spike). + /// /// private void DrainAndApply() { - var drained = _drainCompletions(MaxCompletionsPerFrame); - foreach (var result in drained) + int budget = MaxCompletionsPerFrame; + + // --- Step 1: drain the deferred buffer first (items held from a prior + // priority-hunt that overshot the cap). Apply up to `budget` of them. + int deferredApplied = 0; + while (deferredApplied < budget && _deferredApply.Count > 0) { - switch (result) + var item = _deferredApply[0]; + _deferredApply.RemoveAt(0); + ApplyResult(item); + deferredApplied++; + } + budget -= deferredApplied; + + if (budget <= 0) return; + + // --- Step 2: priority hunt (only when a destination is set). + if (PriorityLandblockId != 0u) + { + // Drain in chunks of MaxCompletionsPerFrame (bounded at 64 total + // iterations to avoid an unbounded loop if the outbox is huge). + const int MaxHuntIterations = 64; + int hunted = 0; + bool found = false; + + while (hunted < MaxHuntIterations && !found) { - case LandblockStreamResult.Loaded loaded: - _applyTerrain(loaded.Landblock, loaded.MeshData); - _state.AddLandblock(loaded.Landblock); - // #138: after the landblock is in _loaded (so AppendLiveEntity - // hot-paths), restore any retained server objects ACE won't - // re-send. Fired AFTER AddLandblock, never before. - _onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId); - break; - case LandblockStreamResult.Promoted promoted: - _applyTerrain(promoted.Landblock, promoted.MeshData); - _state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities); - _onLandblockLoaded?.Invoke(promoted.LandblockId); - break; - case LandblockStreamResult.Unloaded unloaded: - _state.RemoveLandblock(unloaded.LandblockId); - _removeTerrain?.Invoke(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; + var chunk = _drainCompletions(MaxCompletionsPerFrame); + if (chunk.Count == 0) break; + + foreach (var result in chunk) + { + hunted++; + if (ResultLandblockId(result) == PriorityLandblockId) + { + ApplyResult(result); + budget--; // priority counts against the budget + found = true; + // Remaining items in this chunk go to deferred — they + // were drained past the cap to find the priority; don't + // drop them. + } + else + { + _deferredApply.Add(result); + } + } } + + if (found) + { + // Now apply up to remaining budget from deferred (mix from the + // chunk we just buffered + any pre-existing deferred items). + while (budget > 0 && _deferredApply.Count > 0) + { + var item = _deferredApply[0]; + _deferredApply.RemoveAt(0); + ApplyResult(item); + budget--; + } + return; + } + // Priority not found in the outbox this frame: fall through to + // normal drain (the deferred buffer was already partially applied + // above; budget was already decremented). + } + + // --- Step 3: normal path — drain up to remaining budget from the + // worker's outbox (priority was 0, or not found this frame). + var drained = _drainCompletions(budget); + foreach (var result in drained) + ApplyResult(result); + } + + /// + /// Apply a single with the full side- + /// effects: terrain upload, GPU state, and the re-hydration callback. + /// Extracted from the inline switch in the original DrainAndApply + /// so both the priority-hunt path and the normal drain path share it. + /// + private void ApplyResult(LandblockStreamResult result) + { + switch (result) + { + case LandblockStreamResult.Loaded loaded: + _applyTerrain(loaded.Landblock, loaded.MeshData); + _state.AddLandblock(loaded.Landblock); + // #138: after the landblock is in _loaded (so AppendLiveEntity + // hot-paths), restore any retained server objects ACE won't + // re-send. Fired AFTER AddLandblock, never before. + _onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId); + break; + case LandblockStreamResult.Promoted promoted: + _applyTerrain(promoted.Landblock, promoted.MeshData); + _state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities); + _onLandblockLoaded?.Invoke(promoted.LandblockId); + break; + case LandblockStreamResult.Unloaded unloaded: + _state.RemoveLandblock(unloaded.LandblockId); + _removeTerrain?.Invoke(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; } } + + /// + /// Returns the landblock id associated with . + /// For this is 0 by + /// convention (not tied to a specific landblock). + /// + private static uint ResultLandblockId(LandblockStreamResult result) => result.LandblockId; } diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs new file mode 100644 index 00000000..d3af4b57 --- /dev/null +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using AcDream.App.Streaming; +using AcDream.Core.Terrain; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using Xunit; + +namespace AcDream.Core.Tests.Streaming; + +public class StreamingControllerPriorityApplyTests +{ + private static LandblockStreamResult.Loaded LoadedOf(uint canonicalId) + => new(canonicalId, LandblockStreamTier.Near, + new LoadedLandblock(canonicalId, new LandBlock(), Array.Empty()), + new LandblockMeshData(Array.Empty(), Array.Empty())); + + [Fact] + public void PriorityLandblock_isApplied_evenWhenBeyondPerFrameCap() + { + uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180); + var outbox = new Queue(new LandblockStreamResult[] + { + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 0)), + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 1)), + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 2)), + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 3)), + LoadedOf(priority), + }); + + var applied = new List(); + var state = new GpuWorldState(); + var ctrl = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: max => + { + var batch = new List(); + while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); + return batch; + }, + applyTerrain: (lb, _) => applied.Add(lb.LandblockId), + state: state, nearRadius: 4, farRadius: 12) + { MaxCompletionsPerFrame = 4 }; + + ctrl.PriorityLandblockId = priority; + ctrl.Tick(169, 180); + + Assert.Contains(priority, applied); // priority applied THIS tick + Assert.True(applied.Count <= 5); // did not blindly flush the whole outbox + } + + [Fact] + public void Deferred_nonPriority_completions_applyOnLaterFrames_withoutLoss() + { + // After the priority is found, the non-priority items drained past it must + // still be applied (over subsequent ticks), never dropped. + uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180); + var outbox = new Queue(new LandblockStreamResult[] + { + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 0)), + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 1)), + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 2)), + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 3)), + LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 4)), + LoadedOf(priority), + }); + var applied = new List(); + var state = new GpuWorldState(); + var ctrl = new StreamingController( + (_, _) => { }, _ => { }, + max => { var b = new List(); while (b.Count < max && outbox.Count > 0) b.Add(outbox.Dequeue()); return b; }, + (lb, _) => applied.Add(lb.LandblockId), + state, 4, 12) { MaxCompletionsPerFrame = 4 }; + ctrl.PriorityLandblockId = priority; + + ctrl.Tick(169, 180); // priority + some others + ctrl.PriorityLandblockId = 0u; + ctrl.Tick(169, 180); // drains the deferred remainder + ctrl.Tick(169, 180); + + Assert.Contains(priority, applied); + Assert.Equal(6, applied.Count); // all six applied, none lost + } +}