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
+ }
+}