feat(streaming): enforce typed completion queues

Replace the flat deferred list, priority scan, unload bypass, and count-only execution cap with exact destination/control/unload/Near/Far FIFOs behind one typed frame meter. Price worker results before adoption, retain exact retry identity, reject stale generations without payload retention, and publish queue pressure through lifecycle diagnostics.

Tests: dotnet build AcDream.slnx -c Release --no-restore; dotnet test AcDream.slnx -c Release --no-restore (8138 passed, 5 skipped)
This commit is contained in:
Erik 2026-07-24 17:48:24 +02:00
parent ac45cb1bd7
commit b8f6317fe1
15 changed files with 1261 additions and 336 deletions

View file

@ -290,7 +290,7 @@ internal sealed class SessionPlayerCompositionPhase
d.WorldOrigin.CenterX,
d.WorldOrigin.CenterY))),
enqueueUnload: streamerLease.Resource.EnqueueUnload,
drainCompletions: streamerLease.Resource.DrainCompletions,
completionSource: streamerLease.Resource,
state: live.WorldState,
nearRadius: nearRadius,
farRadius: farRadius,

View file

@ -43,7 +43,7 @@ namespace AcDream.App.Streaming;
/// methods are thread-safe.
/// </remarks>
/// </summary>
public sealed class LandblockStreamer : IDisposable
public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
{
/// <summary>
/// Default drain batch size. Tuned to cap GPU upload work the render
@ -61,6 +61,7 @@ public sealed class LandblockStreamer : IDisposable
private readonly object _inboxGate = new();
private Thread? _worker;
private Exception? _workerFailure;
private int _completionBacklog;
private int _disposed;
private readonly object _disposeGate = new();
private bool _disposeCompleted;
@ -276,11 +277,37 @@ public sealed class LandblockStreamer : IDisposable
public IReadOnlyList<LandblockStreamResult> DrainCompletions(int maxBatchSize = DefaultDrainBatchSize)
{
var batch = new List<LandblockStreamResult>(maxBatchSize);
while (batch.Count < maxBatchSize && _outbox.Reader.TryRead(out var result))
while (batch.Count < maxBatchSize && TryRead(out var result))
{
if (result is null)
throw new InvalidOperationException(
"The completion channel returned a null result.");
batch.Add(result);
}
return batch;
}
public int BacklogCount => Math.Max(
0,
System.Threading.Volatile.Read(ref _completionBacklog));
public bool TryPeek(out LandblockStreamResult? result) =>
_outbox.Reader.TryPeek(out result);
public bool TryRead(out LandblockStreamResult? result)
{
if (!_outbox.Reader.TryRead(out result))
return false;
System.Threading.Interlocked.Decrement(ref _completionBacklog);
return true;
}
private void PublishResult(LandblockStreamResult result)
{
if (_outbox.Writer.TryWrite(result))
System.Threading.Interlocked.Increment(ref _completionBacklog);
}
private void WorkerLoop()
{
var highPriority = new Queue<LandblockStreamJob>();
@ -337,7 +364,7 @@ public sealed class LandblockStreamer : IDisposable
_workerFailure = ex;
_inbox.Writer.TryComplete(ex);
}
_outbox.Writer.TryWrite(new LandblockStreamResult.WorkerCrashed(ex.ToString()));
PublishResult(new LandblockStreamResult.WorkerCrashed(ex.ToString()));
}
finally
{
@ -426,13 +453,13 @@ public sealed class LandblockStreamer : IDisposable
var build = _loadLandblock(load.Request);
if (build is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
PublishResult(new LandblockStreamResult.Failed(
load.LandblockId, "LandblockLoader.Load returned null", load.Generation));
break;
}
if (build.Origin != load.Origin)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
PublishResult(new LandblockStreamResult.Failed(
load.LandblockId,
$"Landblock build origin {build.Origin} did not match request origin {load.Origin}",
load.Generation));
@ -444,18 +471,18 @@ public sealed class LandblockStreamer : IDisposable
var promotedMesh = _buildMeshOrNull(load.LandblockId, lb);
if (promotedMesh is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
PublishResult(new LandblockStreamResult.Failed(
load.LandblockId, "buildMeshOrNull returned null", load.Generation));
break;
}
_outbox.Writer.TryWrite(new LandblockStreamResult.Promoted(
PublishResult(new LandblockStreamResult.Promoted(
load.LandblockId, build, promotedMesh, load.Generation));
break;
}
var mesh = _buildMeshOrNull(load.LandblockId, lb);
if (mesh is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
PublishResult(new LandblockStreamResult.Failed(
load.LandblockId, "buildMeshOrNull returned null", load.Generation));
break;
}
@ -485,18 +512,18 @@ public sealed class LandblockStreamer : IDisposable
PhysicsDatBundle.Empty);
build = new LandblockBuild(lb, Origin: build.Origin);
}
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
PublishResult(new LandblockStreamResult.Loaded(
load.LandblockId, tier, build, mesh, load.Generation));
}
catch (Exception ex)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
PublishResult(new LandblockStreamResult.Failed(
load.LandblockId, ex.ToString(), load.Generation));
}
break;
case LandblockStreamJob.Unload unload:
_outbox.Writer.TryWrite(new LandblockStreamResult.Unloaded(
PublishResult(new LandblockStreamResult.Unloaded(
unload.LandblockId,
unload.Generation));
break;

View file

@ -0,0 +1,270 @@
using System.Diagnostics;
namespace AcDream.App.Streaming;
/// <summary>
/// Allocation-free single-consumer view of the streaming worker's completion
/// channel. Peek plus read lets the update thread price a payload before
/// adopting it into the scheduler.
/// </summary>
public interface ILandblockCompletionSource
{
int BacklogCount { get; }
bool TryPeek(out LandblockStreamResult? result);
bool TryRead(out LandblockStreamResult? result);
}
internal enum StreamingCompletionPriority : byte
{
Destination = 0,
Control = 1,
Unload = 2,
Near = 3,
Far = 4,
}
internal readonly record struct StreamingQueuedCompletion(
LandblockStreamResult Result,
LandblockStreamCostEstimate Estimate,
StreamingCompletionPriority Priority,
ulong Generation,
long Sequence,
long EnqueuedTimestamp)
{
public StreamingWorkCost AdmissionCost => new(
CompletionAdmissions: Estimate.Work.CompletionAdmissions,
AdoptedCpuBytes: Estimate.Work.AdoptedCpuBytes);
public StreamingWorkCost ExecutionCost => new(
EntityOperations: Estimate.Work.EntityOperations,
GpuUploadBytes: Estimate.Work.GpuUploadBytes,
GlRetireOperations:
Result is LandblockStreamResult.Unloaded ? 1 : 0);
}
internal readonly record struct StreamingCompletionQueueSnapshot(
int Count,
long RetainedCpuBytes,
double OldestAgeMilliseconds,
int Destination,
int Control,
int Unload,
int Near,
int Far);
/// <summary>
/// Render-thread-owned stable FIFO queues. Ordering is exact inside one
/// generation/priority; callbacks append and never invalidate an active node.
/// </summary>
internal sealed class StreamingCompletionQueue
{
private readonly Queue<StreamingQueuedCompletion>[] _queues =
Enumerable.Range(0, Enum.GetValues<StreamingCompletionPriority>().Length)
.Select(static _ => new Queue<StreamingQueuedCompletion>())
.ToArray();
private long _retainedCpuBytes;
private int _count;
public int Count => _count;
public long RetainedCpuBytes => _retainedCpuBytes;
public void Enqueue(StreamingQueuedCompletion completion)
{
_queues[(int)completion.Priority].Enqueue(completion);
_count++;
_retainedCpuBytes = SaturatingAdd(
_retainedCpuBytes,
completion.Estimate.Work.AdoptedCpuBytes);
}
public bool TryPeekNext(
Func<LandblockStreamResult, bool> isBlocked,
out StreamingQueuedCompletion? completion)
{
ArgumentNullException.ThrowIfNull(isBlocked);
for (int priority = 0; priority < _queues.Length; priority++)
{
Queue<StreamingQueuedCompletion> queue = _queues[priority];
if (queue.Count == 0)
continue;
StreamingQueuedCompletion head = queue.Peek();
if (isBlocked(head.Result))
continue;
completion = head;
return true;
}
completion = null;
return false;
}
public void RemoveHead(StreamingQueuedCompletion completion)
{
Queue<StreamingQueuedCompletion> queue =
_queues[(int)completion.Priority];
if (queue.Count == 0)
{
throw new InvalidOperationException(
"Streaming completion priority FIFO is empty.");
}
StreamingQueuedCompletion head = queue.Peek();
if (head.Sequence != completion.Sequence
|| !ReferenceEquals(head.Result, completion.Result))
{
throw new InvalidOperationException(
"Streaming completion is not the head of its priority FIFO.");
}
queue.Dequeue();
Release(completion);
}
public int RemoveResults(
IReadOnlyList<LandblockStreamResult> results,
Func<LandblockStreamResult, bool> shouldRemove)
{
ArgumentNullException.ThrowIfNull(results);
ArgumentNullException.ThrowIfNull(shouldRemove);
int removed = 0;
for (int priority = 0; priority < _queues.Length; priority++)
{
Queue<StreamingQueuedCompletion> queue = _queues[priority];
int count = queue.Count;
for (int entry = 0; entry < count; entry++)
{
StreamingQueuedCompletion current = queue.Dequeue();
bool matches = false;
for (int i = 0; i < results.Count; i++)
{
if (ReferenceEquals(current.Result, results[i]))
{
matches = true;
break;
}
}
if (matches && shouldRemove(current.Result))
{
Release(current);
removed++;
}
else
{
queue.Enqueue(current);
}
}
}
return removed;
}
public void Clear()
{
for (int i = 0; i < _queues.Length; i++)
_queues[i].Clear();
_count = 0;
_retainedCpuBytes = 0;
}
public StreamingCompletionQueueSnapshot CaptureSnapshot()
{
long now = Stopwatch.GetTimestamp();
long oldest = now;
bool found = false;
for (int i = 0; i < _queues.Length; i++)
{
foreach (StreamingQueuedCompletion completion in _queues[i])
{
oldest = Math.Min(oldest, completion.EnqueuedTimestamp);
found = true;
}
}
return new StreamingCompletionQueueSnapshot(
_count,
_retainedCpuBytes,
found
? Math.Max(0L, now - oldest) * 1000.0 / Stopwatch.Frequency
: 0,
_queues[(int)StreamingCompletionPriority.Destination].Count,
_queues[(int)StreamingCompletionPriority.Control].Count,
_queues[(int)StreamingCompletionPriority.Unload].Count,
_queues[(int)StreamingCompletionPriority.Near].Count,
_queues[(int)StreamingCompletionPriority.Far].Count);
}
private void Release(StreamingQueuedCompletion completion)
{
_count--;
_retainedCpuBytes = Math.Max(
0,
_retainedCpuBytes - completion.Estimate.Work.AdoptedCpuBytes);
}
private static long SaturatingAdd(long left, long right) =>
left > long.MaxValue - right ? long.MaxValue : left + right;
}
/// <summary>
/// Compatibility adapter for deterministic tests that still provide the
/// former batch-drain delegate. Production uses
/// <see cref="ILandblockCompletionSource"/> directly.
/// </summary>
internal sealed class DelegateLandblockCompletionSource(
Func<int, IReadOnlyList<LandblockStreamResult>> drain)
: ILandblockCompletionSource
{
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drain =
drain ?? throw new ArgumentNullException(nameof(drain));
private LandblockStreamResult? _peeked;
public int BacklogCount => _peeked is null ? 0 : 1;
public bool TryPeek(out LandblockStreamResult? result)
{
if (_peeked is null)
{
IReadOnlyList<LandblockStreamResult> batch = _drain(1);
if (batch.Count > 1)
{
throw new InvalidOperationException(
"A single-result completion drain returned more than one result.");
}
if (batch.Count == 0)
{
result = null;
return false;
}
_peeked = batch[0];
}
result = _peeked;
return true;
}
public bool TryRead(out LandblockStreamResult? result)
{
if (_peeked is not null)
{
result = _peeked;
_peeked = null;
return true;
}
IReadOnlyList<LandblockStreamResult> batch = _drain(1);
if (batch.Count > 1)
{
throw new InvalidOperationException(
"A single-result completion drain returned more than one result.");
}
if (batch.Count == 0)
{
result = null;
return false;
}
result = batch[0];
return true;
}
}

View file

@ -19,17 +19,12 @@ namespace AcDream.App.Streaming;
/// </summary>
public sealed class StreamingController : IStreamingFrameBackend
{
private readonly record struct DeferredCompletion(
LandblockStreamResult Result,
StreamingWorkCost Cost,
long EnqueuedTimestamp);
private sealed class OriginRecenterRetirement
{
public bool RadiiConverged;
public bool GenerationAdvanced;
public bool PendingLoadsCleared;
public bool DeferredApplyCleared;
public bool CompletionQueueCleared;
public bool RegionCleared;
public List<uint>? ResidentIds;
public int RetirementCursor;
@ -41,13 +36,18 @@ public sealed class StreamingController : IStreamingFrameBackend
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
private readonly Action<uint, ulong> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly ILandblockCompletionSource _completionSource;
private readonly Action? _clearPendingLoads;
private readonly LandblockPresentationPipeline _presentation;
private readonly StreamingWorkBudget _workBudget;
private readonly Func<LandblockStreamResult, bool>
_isPublicationBlockedByRetirement;
private readonly StreamingWorkBudgetOptions _configuredWorkBudgetOptions;
private StreamingWorkBudget _workBudget;
private StreamingWorkMeter? _activeWorkMeter;
private StreamingWorkMeterSnapshot _lastWorkMeter;
private long _deferredAdoptedCpuBytes;
private readonly StreamingCompletionQueue _completionQueue = new();
private long _nextCompletionSequence;
private int _legacyCompletionProfile = 4;
private readonly GpuWorldState _state;
private StreamingRegion? _region;
@ -88,34 +88,28 @@ public sealed class StreamingController : IStreamingFrameBackend
public int FarRadius { get; private set; }
/// <summary>
/// Cap on completions drained per <see cref="Tick"/> call. The cap is
/// the GPU upload budget for one frame: terrain mesh + per-entity GfxObj
/// sub-mesh uploads + texture uploads for one landblock take a few ms;
/// applying 25 of them in a single frame produces a memory spike
/// (observed: out-of-memory crash on the 5×5 first-frame load).
///
/// <para>
/// 4 is the original async-streamer value; it spreads a 5×5 first-frame
/// load over ~7 frames (~116ms at 60fps), which is below the human
/// perception threshold. Spawn races that previously dropped entities
/// while landblocks were in flight are now handled by
/// <see cref="GpuWorldState"/>'s pending-spawn list, so spreading
/// completions doesn't lose any data.
/// </para>
/// Compatibility quality selector for callers that still express the
/// streaming profile as a completion count. Four preserves the configured
/// High profile; other positive values scale every typed time/count/byte
/// dimension together. It is not an independent execution throttle.
/// </summary>
public int MaxCompletionsPerFrame { get; set; } = 4;
public int MaxCompletionsPerFrame
{
get => _legacyCompletionProfile;
set
{
StreamingWorkBudgetOptions profile =
_configuredWorkBudgetOptions
.ScaleForLegacyCompletionCount(value);
_legacyCompletionProfile = value;
_workBudget = profile.ToBudget();
}
}
/// <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.
/// #138: the teleport destination landblock id. Loaded or promoted results
/// inside its priority ring enter the destination FIFO, ahead of ordinary
/// Near/Far work, but still consume the same typed frame budget.
///
/// <para>Set by <c>GameWindow</c> when a teleport starts; reset to 0
/// once the destination landblock has been applied (or when the
@ -124,32 +118,39 @@ public sealed class StreamingController : IStreamingFrameBackend
public uint PriorityLandblockId { get; set; }
/// <summary>
/// 2026-06-22: the radius (in landblocks, Chebyshev) around
/// <see cref="PriorityLandblockId"/> that <see cref="DrainAndApply"/> eager-applies
/// ahead of the per-frame budget. 0 (default) = only the single center landblock — the
/// original priority behaviour. A teleport sets this to the near ring so the player's
/// IMMEDIATE SURROUNDINGS (terrain + collision + scenery) are resident on arrival, not
/// just the one landblock they stand on. Without it, only the destination landblock
/// applies immediately and everything around it drains at <see cref="MaxCompletionsPerFrame"/>,
/// so the player arrives to a near-empty world (the "Fort Tethana only one landblock
/// loaded" symptom) and their cell-walk can't root into neighbour cells yet (the
/// transient walk-through-walls). The far ring still drains at the budget. The eager
/// apply runs while the portal viewport replaces the world, so the GPU spike is hidden.
/// Radius in landblocks (Chebyshev distance) around
/// <see cref="PriorityLandblockId"/> classified as destination work.
/// Zero means only the center. This changes queue priority, never budget
/// enforcement or atomic publication semantics.
/// </summary>
public int PriorityRadius { get; set; }
// [FRAME-DIAG] (read by GameWindow's ACDREAM_WB_DIAG rollup): the standing
// deferred-LOAD backlog. A non-zero value during/after a teleport is the
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface.
public int DeferredApplyBacklog => _deferredApply.Count;
// accepted completion backlog. A non-zero value during/after a teleport is
// the typed-budget publication tail. Removable compatibility probe surface.
public int DeferredApplyBacklog => _completionQueue.Count;
public int PendingRetirementCount => _presentation.PendingRetirementCount;
public StreamingWorkDiagnostics WorkDiagnostics => new(
_lastWorkMeter,
_deferredApply.Count,
_deferredAdoptedCpuBytes,
OldestDeferredAgeMilliseconds(),
_presentation.PendingPublicationCount,
_presentation.PendingRetirementCount);
public StreamingWorkDiagnostics WorkDiagnostics
{
get
{
StreamingCompletionQueueSnapshot queued =
_completionQueue.CaptureSnapshot();
return new StreamingWorkDiagnostics(
_lastWorkMeter,
queued.Count,
queued.RetainedCpuBytes,
queued.OldestAgeMilliseconds,
_presentation.PendingPublicationCount,
_presentation.PendingRetirementCount,
_completionSource.BacklogCount,
queued.Destination,
queued.Control,
queued.Unload,
queued.Near,
queued.Far);
}
}
internal bool IsCollapsedToDungeon => _collapsed;
/// <summary>
@ -195,9 +196,6 @@ public sealed class StreamingController : IStreamingFrameBackend
public int FullWindowRetirementCount { get; private set; }
public int LastFullWindowRetirementLandblockCount { get; private set; }
// Completions that were drained past a priority item get buffered here
// so they still apply over subsequent frames without loss.
private readonly List<DeferredCompletion> _deferredApply = new();
/// <summary>
/// Internal compatibility seam for hermetic controller policy tests. Live
/// composition cannot supply presentation callbacks; it must use the
@ -216,7 +214,8 @@ public sealed class StreamingController : IStreamingFrameBackend
Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null)
LandblockRetirementCoordinator? retirementCoordinator = null,
StreamingWorkBudgetOptions? workBudgetOptions = null)
: this(
enqueueLoad,
enqueueUnload,
@ -232,7 +231,8 @@ public sealed class StreamingController : IStreamingFrameBackend
retirementCoordinator,
removeTerrain,
demoteNearLayer),
clearPendingLoads)
clearPendingLoads,
workBudgetOptions)
{
}
@ -241,7 +241,7 @@ public sealed class StreamingController : IStreamingFrameBackend
/// retirement owner, so no legacy publication delegate can be supplied or
/// silently ignored.
/// </summary>
public StreamingController(
internal StreamingController(
Action<uint, LandblockStreamJobKind, ulong> enqueueLoad,
Action<uint, ulong> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
@ -251,16 +251,47 @@ public sealed class StreamingController : IStreamingFrameBackend
LandblockPresentationPipeline presentationPipeline,
Action? clearPendingLoads = null,
StreamingWorkBudgetOptions? workBudgetOptions = null)
: this(
enqueueLoad,
enqueueUnload,
new DelegateLandblockCompletionSource(drainCompletions),
state,
nearRadius,
farRadius,
presentationPipeline,
clearPendingLoads,
workBudgetOptions)
{
}
/// <summary>
/// Allocation-free production completion path. Peek/read is single-consumer
/// and lets the scheduler price a result before adopting it.
/// </summary>
public StreamingController(
Action<uint, LandblockStreamJobKind, ulong> enqueueLoad,
Action<uint, ulong> enqueueUnload,
ILandblockCompletionSource completionSource,
GpuWorldState state,
int nearRadius,
int farRadius,
LandblockPresentationPipeline presentationPipeline,
Action? clearPendingLoads = null,
StreamingWorkBudgetOptions? workBudgetOptions = null)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
_completionSource = completionSource
?? throw new ArgumentNullException(nameof(completionSource));
_clearPendingLoads = clearPendingLoads;
_state = state;
_presentation = presentationPipeline
?? throw new ArgumentNullException(nameof(presentationPipeline));
_workBudget = (workBudgetOptions ?? StreamingWorkBudgetOptions.Default)
.ToBudget();
_isPublicationBlockedByRetirement =
IsPublicationBlockedByRetirement;
_configuredWorkBudgetOptions =
workBudgetOptions ?? StreamingWorkBudgetOptions.Default;
_workBudget = _configuredWorkBudgetOptions.ToBudget();
if (!_presentation.MatchesState(_state))
{
throw new ArgumentException(
@ -400,7 +431,8 @@ public sealed class StreamingController : IStreamingFrameBackend
Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null)
LandblockRetirementCoordinator? retirementCoordinator = null,
StreamingWorkBudgetOptions? workBudgetOptions = null)
: this(
(id, kind, _) => enqueueLoad(id, kind),
(id, _) => enqueueUnload(id),
@ -414,7 +446,8 @@ public sealed class StreamingController : IStreamingFrameBackend
clearPendingLoads,
onLandblockLoaded,
ensureEnvCellMeshes,
retirementCoordinator)
retirementCoordinator,
workBudgetOptions)
{
}
@ -429,48 +462,47 @@ public sealed class StreamingController : IStreamingFrameBackend
_presentation.BeginNearLayerRetirement(canonical);
}
private void AdvanceGeneration()
private bool AdvanceGeneration()
{
ConvergePendingPublications();
if (!ConvergePendingPublications())
return false;
_generation = unchecked(_generation + 1);
return true;
}
private void ConvergePendingPublications()
private bool ConvergePendingPublications()
{
IReadOnlyList<LandblockStreamResult> pending =
_presentation.GetPendingPublicationResults();
if (pending.Count == 0)
return;
return true;
try
{
bool progressed = false;
for (int i = 0; i < pending.Count; i++)
{
LandblockStreamResult result = pending[i];
ObserveResultOperation(
if (!TryObserveResultOperation(
result,
"publication-retry",
() => _presentation.ResumePublication(result));
() => _presentation.ResumePublication(result),
ensureProgress: !progressed))
{
return false;
}
progressed = true;
}
return true;
}
finally
{
// A completed staged result can still be present in the deferred
// FIFO that originally retained it. Remove only those exact
// objects; unrelated accepted completions keep their order.
_deferredApply.RemoveAll(entry =>
{
for (int i = 0; i < pending.Count; i++)
{
if (ReferenceEquals(entry.Result, pending[i]))
{
return !_presentation.HasPendingPublication(
entry.Result);
}
}
return false;
});
RecalculateDeferredAdoptedBytes();
_completionQueue.RemoveResults(
pending,
result => !_presentation.HasPendingPublication(result));
}
}
@ -518,7 +550,8 @@ public sealed class StreamingController : IStreamingFrameBackend
// Complete an admitted publication before this frame can mutate its
// desired tier or spatial residence. A later recenter may then demote
// or retire the fully known owner set through the normal ledger.
ConvergePendingPublications();
if (!ConvergePendingPublications())
return;
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
@ -574,7 +607,8 @@ public sealed class StreamingController : IStreamingFrameBackend
if (!pending.GenerationAdvanced)
{
AdvanceGeneration();
if (!AdvanceGeneration())
return;
pending.GenerationAdvanced = true;
}
@ -625,7 +659,7 @@ public sealed class StreamingController : IStreamingFrameBackend
_region = pending.Region;
NearRadius = pending.NearRadius;
FarRadius = pending.FarRadius;
ClearDeferred();
_completionQueue.Clear();
_pendingRadiiReconfiguration = null;
}
}
@ -762,11 +796,12 @@ public sealed class StreamingController : IStreamingFrameBackend
bool logTransition = !_collapsed || _collapsedCenter != centerId;
if (logTransition)
Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}");
AdvanceGeneration();
if (!AdvanceGeneration())
return;
_collapsed = true;
_collapsedCenter = centerId;
_clearPendingLoads?.Invoke();
ClearDeferred();
_completionQueue.Clear();
_region = null;
foreach (var id in _state.LoadedLandblockIds)
@ -811,7 +846,7 @@ public sealed class StreamingController : IStreamingFrameBackend
/// <summary>
/// True when <paramref name="id"/> is the priority center or within
/// <see cref="PriorityRadius"/> landblocks of it (Chebyshev) — i.e. inside the teleport
/// near ring that <see cref="DrainAndApply"/> eager-applies. With the default radius 0
/// near ring that <see cref="DrainAndApply"/> prioritizes. With the default radius 0
/// this reduces to an exact match on <see cref="PriorityLandblockId"/> (the original
/// single-landblock priority behaviour).
/// </summary>
@ -829,7 +864,8 @@ public sealed class StreamingController : IStreamingFrameBackend
Console.WriteLine(
$"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " +
$"(was collapsed on 0x{_collapsedCenter:X8})");
AdvanceGeneration();
if (!AdvanceGeneration())
return;
_collapsed = false;
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
@ -1027,7 +1063,8 @@ public sealed class StreamingController : IStreamingFrameBackend
}
if (!transaction.GenerationAdvanced)
{
AdvanceGeneration();
if (!AdvanceGeneration())
return false;
transaction.GenerationAdvanced = true;
}
if (!transaction.PendingLoadsCleared)
@ -1045,10 +1082,10 @@ public sealed class StreamingController : IStreamingFrameBackend
return false;
}
}
if (!transaction.DeferredApplyCleared)
if (!transaction.CompletionQueueCleared)
{
ClearDeferred();
transaction.DeferredApplyCleared = true;
_completionQueue.Clear();
transaction.CompletionQueueCleared = true;
}
if (!transaction.RegionCleared)
{
@ -1103,10 +1140,11 @@ public sealed class StreamingController : IStreamingFrameBackend
private void BeginFullWindowRetirement()
{
AdvanceGeneration();
if (!AdvanceGeneration())
return;
_collapsed = false;
_clearPendingLoads?.Invoke();
ClearDeferred();
_completionQueue.Clear();
// Commit the old region boundary before any presentation owner is
// advanced. New same-id publications are fenced by the presentation
// pipeline's retryable retirement ledger.
@ -1120,164 +1158,171 @@ public sealed class StreamingController : IStreamingFrameBackend
}
/// <summary>
/// Apply streamed completions for this frame. LOADS (terrain mesh GPU uploads) are the
/// expensive part, so they are metered at <see cref="MaxCompletionsPerFrame"/> to avoid a
/// GPU-upload spike; the overflow buffers in <see cref="_deferredApply"/> and drains over
/// subsequent frames. UNLOADS are cheap (they free GPU buffers — no upload) and are applied
/// IMMEDIATELY, never throttled: a teleport produces a whole window of unloads (~600), and
/// metering them at the load rate left the previous location's terrain resident for seconds
/// (rendering at its old world position as "floating terrain at the horizon"), and rapid
/// hops accumulated them faster than they cleared — a runaway resident count (951 observed
/// vs a 625 window) that also dragged FPS. Loads inside the teleport near ring
/// (<see cref="PriorityRadius"/>, applied during portal travel) likewise bypass the budget so the
/// player materialises in a loaded world.
/// Adopts immutable worker results and advances explicit priority FIFOs
/// under the one typed frame meter. Destination and unload work win queue
/// order but no class bypasses time, bytes, entities, uploads, or retire
/// operation limits.
/// </summary>
private void DrainAndApply()
{
// --- Step 1: drain the outbox in bounded chunks. Apply unloads + priority near-ring
// loads immediately; defer every other (budget-metered) load. Draining the whole
// outbox each frame (bounded by MaxDrainIterations) is what lets unloads flush
// promptly regardless of the load backlog — the throttle is on GPU UPLOADS, not on
// freeing them. The drain cap must NOT be gated behind the per-frame load budget
// (the prior version returned once the budget hit 0, stranding the outbox).
const int MaxDrainIterations = 64; // cap at 64 * MaxCompletionsPerFrame drained/frame
int iter = 0;
while (iter++ < MaxDrainIterations)
{
var chunk = _drainCompletions(MaxCompletionsPerFrame);
if (chunk.Count == 0) break;
for (int chunkIndex = 0; chunkIndex < chunk.Count; chunkIndex++)
{
LandblockStreamResult result = chunk[chunkIndex];
// Reject obsolete hard-window work before it can occupy the
// metered deferred queue. ApplyResult repeats this invariant
// for direct callers and future drain paths.
if (IsStaleGeneration(result)
&& !_presentation.HasPendingPublication(result))
continue;
if (IsPublicationBlockedByRetirement(result))
Defer(result);
else if (result is LandblockStreamResult.Unloaded
|| IsWithinPriorityRing(ResultLandblockId(result)))
{
try
{
ApplyResultObserved(
result,
"priority-or-unload");
}
catch
{
// The concrete presentation pipeline retains the exact
// unfinished stage. Preserve it and every untouched
// completion from this already-drained chunk in
// original FIFO order. Internal compatibility tests may
// use a callback pipeline that cannot retain a receipt;
// its failed result is consumed, but the untouched
// suffix must still survive.
if (_presentation.HasPendingPublication(result))
Defer(result);
for (int suffix = chunkIndex + 1; suffix < chunk.Count; suffix++)
Defer(chunk[suffix]);
throw;
}
}
else
Defer(result); // a GPU-upload load — meter it in step 2
}
}
StreamingWorkMeter meter = _activeWorkMeter
?? throw new InvalidOperationException(
"Completion scheduling requires an active frame meter.");
// --- Step 2: apply the deferred LOAD backlog at the per-frame budget (FIFO, so
// earlier-queued landblocks win). Caps GPU upload per frame so a big diff doesn't
// spike. _deferredApply now only ever holds loads — unloads were applied in step 1.
int budget = MaxCompletionsPerFrame;
int applied = 0;
int read = 0;
int write = 0;
try
AdmitCompletions(meter);
bool executed = false;
while (_completionQueue.TryPeekNext(
_isPublicationBlockedByRetirement,
out StreamingQueuedCompletion? completion))
{
while (applied < budget && read < _deferredApply.Count)
{
DeferredCompletion entry = _deferredApply[read];
LandblockStreamResult result = entry.Result;
if (IsPublicationBlockedByRetirement(result))
{
if (write != read)
_deferredApply[write] = entry;
write++;
read++;
continue;
}
StreamingQueuedCompletion work = completion
?? throw new InvalidOperationException(
"The completion queue returned a null head.");
StreamingWorkCost executionCost =
IsStaleGeneration(work.Result)
? default
: work.ExecutionCost;
StreamingWorkAdmission admission = meter.TryReserve(
executionCost,
$"execute-{work.Priority}",
ensureProgress: !executed);
if (admission == StreamingWorkAdmission.Yielded)
break;
// Advance read only after the apply commits. If it throws, the
// finally block removes prior committed entries but retains this
// exact result and the untouched tail for retry.
try
{
ApplyResultObserved(
result,
"deferred-publication",
entry.Cost);
read++;
applied++;
}
catch
{
// A concrete suffix-stage failure keeps its pipeline
// receipt and must remain at this exact FIFO position. The
// internal compatibility callback cannot report its exact
// committed prefix and therefore removes its receipt;
// consume only that failed result so a later frame cannot
// replay it automatically.
if (!_presentation.HasPendingPublication(result))
read++;
throw;
}
try
{
ApplyResult(work.Result);
_completionQueue.RemoveHead(work);
meter.Complete();
executed = true;
}
catch
{
// Concrete publication failures retain their exact owner
// receipt and queue head. A compatibility callback without a
// receipt cannot safely replay, so consume only that result.
if (!_presentation.HasPendingPublication(work.Result))
_completionQueue.RemoveHead(work);
meter.Fail();
throw;
}
}
finally
{
// One linear tail shift replaces budget-many RemoveAt shifts. The
// retained blocked prefix and the untouched FIFO tail keep their
// original relative order.
if (read > write)
RemoveDeferredRange(write, read - write);
}
}
private void ApplyResultObserved(
LandblockStreamResult result,
string stage,
StreamingWorkCost? knownCost = null) =>
ObserveResultOperation(
result,
stage,
() => ApplyResult(result),
knownCost);
private void AdmitCompletions(StreamingWorkMeter meter)
{
while (_completionSource.TryPeek(out LandblockStreamResult? peeked))
{
LandblockStreamResult result = peeked
?? throw new InvalidOperationException(
"The completion source returned a null peek.");
bool stale = IsStaleGeneration(result)
&& !_presentation.HasPendingPublication(result);
LandblockStreamCostEstimate estimate =
LandblockStreamResultCost.Estimate(result);
StreamingWorkCost admissionCost = stale
? new StreamingWorkCost(CompletionAdmissions: 1)
: new StreamingWorkCost(
CompletionAdmissions:
estimate.Work.CompletionAdmissions,
AdoptedCpuBytes:
estimate.Work.AdoptedCpuBytes);
StreamingWorkAdmission admission = meter.TryReserve(
admissionCost,
"completion-admission");
if (admission == StreamingWorkAdmission.Yielded)
return;
private void ObserveResultOperation(
try
{
if (!_completionSource.TryRead(
out LandblockStreamResult? consumed)
|| !ReferenceEquals(result, consumed))
{
throw new InvalidOperationException(
"The single-consumer completion source changed between peek and read.");
}
if (!stale)
{
_completionQueue.Enqueue(new StreamingQueuedCompletion(
result,
estimate,
ClassifyCompletion(result),
result.Generation,
checked(_nextCompletionSequence++),
Stopwatch.GetTimestamp()));
}
meter.Complete();
}
catch
{
meter.Fail();
throw;
}
}
}
private StreamingCompletionPriority ClassifyCompletion(
LandblockStreamResult result)
{
if (result is LandblockStreamResult.Loaded
or LandblockStreamResult.Promoted
&& IsWithinPriorityRing(result.LandblockId))
{
return StreamingCompletionPriority.Destination;
}
return result switch
{
LandblockStreamResult.Failed
or LandblockStreamResult.WorkerCrashed =>
StreamingCompletionPriority.Control,
LandblockStreamResult.Unloaded =>
StreamingCompletionPriority.Unload,
LandblockStreamResult.Promoted
or LandblockStreamResult.Loaded
{
Tier: LandblockStreamTier.Near,
} =>
StreamingCompletionPriority.Near,
_ => StreamingCompletionPriority.Far,
};
}
private bool TryObserveResultOperation(
LandblockStreamResult result,
string stage,
Action operation,
StreamingWorkCost? knownCost = null)
StreamingWorkCost? knownCost = null,
bool ensureProgress = false)
{
StreamingWorkMeter? meter = _activeWorkMeter;
if (meter is null)
{
operation();
return;
return true;
}
StreamingWorkCost cost = knownCost
StreamingWorkCost aggregate = knownCost
?? LandblockStreamResultCost.Estimate(result).Work;
StreamingWorkAdmission admission = meter.TryReserve(cost, stage);
StreamingWorkCost cost = new(
EntityOperations: aggregate.EntityOperations,
GpuUploadBytes: aggregate.GpuUploadBytes,
GlRetireOperations:
result is LandblockStreamResult.Unloaded ? 1 : 0);
StreamingWorkAdmission admission = meter.TryReserve(
cost,
stage,
ensureProgress);
if (admission == StreamingWorkAdmission.Yielded)
meter.ForceReserveObserved(cost, stage);
return false;
try
{
operation();
meter.Complete();
return true;
}
catch
{
@ -1286,69 +1331,10 @@ public sealed class StreamingController : IStreamingFrameBackend
}
}
private void Defer(LandblockStreamResult result)
{
StreamingWorkCost cost = LandblockStreamResultCost.Estimate(result).Work;
_deferredApply.Add(new DeferredCompletion(
result,
cost,
Stopwatch.GetTimestamp()));
_deferredAdoptedCpuBytes = SaturatingAdd(
_deferredAdoptedCpuBytes,
cost.AdoptedCpuBytes);
}
private void ClearDeferred()
{
_deferredApply.Clear();
_deferredAdoptedCpuBytes = 0;
}
private void RemoveDeferredRange(int index, int count)
{
for (int i = index; i < index + count; i++)
{
_deferredAdoptedCpuBytes = Math.Max(
0,
_deferredAdoptedCpuBytes
- _deferredApply[i].Cost.AdoptedCpuBytes);
}
_deferredApply.RemoveRange(index, count);
}
private void RecalculateDeferredAdoptedBytes()
{
long total = 0;
for (int i = 0; i < _deferredApply.Count; i++)
{
total = SaturatingAdd(
total,
_deferredApply[i].Cost.AdoptedCpuBytes);
}
_deferredAdoptedCpuBytes = total;
}
private double OldestDeferredAgeMilliseconds()
{
if (_deferredApply.Count == 0)
return 0;
long oldest = _deferredApply[0].EnqueuedTimestamp;
for (int i = 1; i < _deferredApply.Count; i++)
oldest = Math.Min(oldest, _deferredApply[i].EnqueuedTimestamp);
return Math.Max(0L, Stopwatch.GetTimestamp() - oldest)
* 1000.0
/ Stopwatch.Frequency;
}
private static long SaturatingAdd(long left, long right) =>
left > long.MaxValue - right ? long.MaxValue : left + right;
/// <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.
/// All priority queues route through this one publication path.
/// </summary>
private void ApplyResult(LandblockStreamResult result)
{

View file

@ -159,7 +159,13 @@ public readonly record struct StreamingWorkDiagnostics(
long DeferredAdoptedCpuBytes,
double OldestDeferredAgeMilliseconds,
int PendingPublications,
int PendingRetirements);
int PendingRetirements,
int WorkerCompletionBacklog,
int DestinationBacklog,
int ControlBacklog,
int UnloadBacklog,
int NearBacklog,
int FarBacklog);
/// <summary>
/// Single-thread, frame-scoped admission meter. Callers reserve a known cost
@ -180,6 +186,7 @@ public sealed class StreamingWorkMeter
private int _failures;
private bool _frameOverrunRecorded;
private bool _reservationActive;
private bool _ensuredProgressGranted;
private string? _activeStage;
private string? _lastStage;
private StreamingWorkLimit _lastLimit;
@ -207,7 +214,8 @@ public sealed class StreamingWorkMeter
public StreamingWorkAdmission TryReserve(
StreamingWorkCost cost,
string stage)
string stage,
bool ensureProgress = false)
{
cost.Validate();
ArgumentException.ThrowIfNullOrWhiteSpace(stage);
@ -216,7 +224,11 @@ public sealed class StreamingWorkMeter
"The prior streaming operation has not completed.");
StreamingWorkLimit limit = FindLimit(cost);
if (limit != StreamingWorkLimit.None && _operations != 0)
bool grantEnsuredProgress =
ensureProgress && !_ensuredProgressGranted;
if (limit != StreamingWorkLimit.None
&& _operations != 0
&& !grantEnsuredProgress)
{
_yields++;
_lastStage = stage;
@ -228,14 +240,21 @@ public sealed class StreamingWorkMeter
_operations++;
_reservationActive = true;
_activeStage = stage;
_lastStage = stage;
_lastLimit = limit;
if (ensureProgress)
_ensuredProgressGranted = true;
if (limit != StreamingWorkLimit.None)
{
_lastStage = stage;
_lastLimit = limit;
_oversizedProgress++;
return StreamingWorkAdmission.OversizedProgress;
}
// Preserve the most recent constrained stage so later successful work
// in another budget dimension cannot erase the reason this frame
// yielded. With no constraint, retain the ordinary last-stage fact.
if (_lastLimit == StreamingWorkLimit.None)
_lastStage = stage;
return StreamingWorkAdmission.Admitted;
}

View file

@ -65,6 +65,31 @@ public sealed record StreamingWorkBudgetOptions(
MaxGlRetireOperations,
DestinationReserveFraction);
/// <summary>
/// Compatibility bridge for the pre-Slice-E quality setting. The former
/// landblock count now selects a complete work profile instead of acting
/// as a second hidden execution throttle. Four is the historical High
/// profile and therefore preserves this configured profile exactly.
/// </summary>
public StreamingWorkBudgetOptions ScaleForLegacyCompletionCount(int count)
{
if (count <= 0)
throw new ArgumentOutOfRangeException(nameof(count));
double scale = count / 4.0;
return this with
{
MaxUpdateMilliseconds = Math.Max(
0.25,
MaxUpdateMilliseconds * scale),
MaxCompletionAdmissions = Scale(MaxCompletionAdmissions, scale),
MaxAdoptedCpuBytes = Scale(MaxAdoptedCpuBytes, scale),
MaxEntityOperations = Scale(MaxEntityOperations, scale),
MaxGpuUploadBytes = Scale(MaxGpuUploadBytes, scale),
MaxGlRetireOperations = Scale(MaxGlRetireOperations, scale),
};
}
private static double ParsePositiveDouble(string? value, double fallback) =>
double.TryParse(
value,
@ -118,4 +143,22 @@ public sealed record StreamingWorkBudgetOptions(
return percent / 100f;
}
private static int Scale(int value, double scale)
{
if (scale >= int.MaxValue / (double)value)
return int.MaxValue;
return Math.Max(
1,
(int)Math.Round(value * scale, MidpointRounding.AwayFromZero));
}
private static long Scale(long value, double scale)
{
if (scale >= long.MaxValue / (double)value)
return long.MaxValue;
return Math.Max(
1L,
(long)Math.Round(value * scale, MidpointRounding.AwayFromZero));
}
}