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