feat(teleport A): StreamingController priority-apply for the teleport destination LB

Adds PriorityLandblockId (uint, default 0) + _deferredApply buffer.
DrainAndApply now: (1) applies up to budget from the deferred buffer,
(2) when PriorityLandblockId != 0, hunts the worker outbox in chunks
applying the priority LB immediately on match and buffering any
non-priority items drained past it for later frames,
(3) falls back to normal drain when no priority is set or not found.

Extracts ApplyResult(result) + ResultLandblockId(result) helpers so
both the priority and normal paths share identical side-effects.
No existing behaviour changes on the non-priority path.

21 streaming tests pass (19 existing + 2 new priority-apply tests).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-22 12:57:27 +02:00
parent b869128df3
commit 1f6baa6cfc
2 changed files with 228 additions and 28 deletions

View file

@ -89,6 +89,28 @@ public sealed class StreamingController
/// </summary>
public int MaxCompletionsPerFrame { get; set; } = 4;
/// <summary>
/// #138: the teleport destination landblock id. When non-zero,
/// <see cref="DrainAndApply"/> applies this landblock's
/// <see cref="LandblockStreamResult.Loaded"/> or
/// <see cref="LandblockStreamResult.Promoted"/> completion immediately
/// — even if it sits past the <see cref="MaxCompletionsPerFrame"/>
/// 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 <see cref="_deferredApply"/> and applied over subsequent frames,
/// so no completions are lost and there is no GPU spike.
///
/// <para>Set by <c>GameWindow</c> when a teleport starts; reset to 0
/// once the destination landblock has been applied (or when the
/// teleport destination changes).</para>
/// </summary>
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<LandblockStreamResult> _deferredApply = new();
public StreamingController(
Action<uint, LandblockStreamJobKind> enqueueLoad,
Action<uint> enqueueUnload,
@ -299,40 +321,133 @@ public sealed class StreamingController
/// <summary>
/// Drain up to N completions per frame so a big diff doesn't spike GPU
/// upload time. Remaining completions wait for the next frame.
///
/// <para>
/// When <see cref="PriorityLandblockId"/> 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
/// <see cref="_deferredApply"/> and applied over subsequent frames
/// (no loss, no GPU spike).
/// </para>
/// </summary>
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);
}
/// <summary>
/// Apply a single <see cref="LandblockStreamResult"/> with the full side-
/// effects: terrain upload, GPU state, and the re-hydration callback.
/// Extracted from the inline switch in the original <c>DrainAndApply</c>
/// so both the priority-hunt path and the normal drain path share it.
/// </summary>
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;
}
}
/// <summary>
/// Returns the landblock id associated with <paramref name="result"/>.
/// For <see cref="LandblockStreamResult.WorkerCrashed"/> this is 0 by
/// convention (not tied to a specific landblock).
/// </summary>
private static uint ResultLandblockId(LandblockStreamResult result) => result.LandblockId;
}

View file

@ -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<WorldEntity>()),
new LandblockMeshData(Array.Empty<TerrainVertex>(), Array.Empty<uint>()));
[Fact]
public void PriorityLandblock_isApplied_evenWhenBeyondPerFrameCap()
{
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
var outbox = new Queue<LandblockStreamResult>(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<uint>();
var state = new GpuWorldState();
var ctrl = new StreamingController(
enqueueLoad: (_, _) => { },
enqueueUnload: _ => { },
drainCompletions: max =>
{
var batch = new List<LandblockStreamResult>();
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<LandblockStreamResult>(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<uint>();
var state = new GpuWorldState();
var ctrl = new StreamingController(
(_, _) => { }, _ => { },
max => { var b = new List<LandblockStreamResult>(); 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
}
}