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

@ -712,11 +712,16 @@ partially hydrated cell membership or shell placement to the renderer.
update-thread streaming cost. `StreamingWorkMeter` owns the frame-scoped update-thread streaming cost. `StreamingWorkMeter` owns the frame-scoped
admission ledger; `LandblockStreamResultCost` assigns deterministic charges to admission ledger; `LandblockStreamResultCost` assigns deterministic charges to
immutable completion arrays and logical retained entries without claiming to immutable completion arrays and logical retained entries without claiming to
measure CLR allocator overhead. During Slice E1 the ledger is observational: measure CLR allocator overhead. `LandblockStreamer` exposes one allocation-free
the existing scheduler still executes its accepted work, while lifecycle single-consumer peek/read source so the update thread prices a result before
artifacts record would-yield limits, elapsed work, retained backlog bytes/age, adopting it. `StreamingController` is the sole scheduler: it admits through the
and pending publication/retirement counts. Enforcement belongs to the explicit typed meter into reusable destination/control/unload/Near/Far FIFOs, preserves
Slice E scheduler, not to presentation owners or the worker. exact reference identity and retry position, rejects stale generations, and
executes every class through the same meter. Priority changes order, never the
budget. Lifecycle artifacts publish worker and per-class backlog, retained
bytes/age, yields, oversizes, overruns, and pending publication/retirement
facts. Publication remains transaction-atomic until Slice E4; retirement
quiesce/cursors belong to E3.
### World-reveal readiness ownership ### World-reveal readiness ownership

View file

@ -1605,8 +1605,13 @@ port in any phase — no separate listing here.
> and dense-Arwic physical routes passed; third-visit cache residence was > and dense-Arwic physical routes passed; third-visit cache residence was
> non-growing and no checkpoint retained staged or retiring bytes. [Residency > non-growing and no checkpoint retained staged or retiring bytes. [Residency
> report](../research/2026-07-24-slice-d-unified-residency-report.md). Slice E > report](../research/2026-07-24-slice-d-unified-residency-report.md). Slice E
> cost-budgeted streaming/retirement is active. F/G and J retain their explicit > cost-budgeted streaming/retirement is active. E2 has replaced the flat
> approval gates. > deferred list, count-only throttle, priority hunt, and unload bypass with one
> typed-meter scheduler over explicit stable destination/control/unload/Near/
> Far queues; production prices worker results before adoption and lifecycle
> artifacts expose exact backlog/retained-byte facts. E3 next adds immediate
> old-generation quiesce and cursored retirement. F/G and J retain their
> explicit approval gates.
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the **Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the
umbrella design — read it first). **Goal:** smoothness first (no frame over umbrella design — read it first). **Goal:** smoothness first (no frame over

View file

@ -93,9 +93,10 @@ The separately authorized modern-runtime performance program has completed
Slices AD: corrected measurement, prepared-package bake/dedup, package-only Slices AD: corrected measurement, prepared-package bake/dedup, package-only
production streaming, and typed unified residency. Its physical capped, production streaming, and typed unified residency. Its physical capped,
uncapped, and dense connected gates pass without a visual-quality change. uncapped, and dense connected gates pass without a visual-quality change.
Slice E cost-budgets portal publication and retirement next. Slices F/G remain Slice E0E2 now define and enforce typed completion admission through explicit
behind their explicit ECS approval gate, and Slice J remains behind its stable priority queues; E3 adds immediate old-generation quiesce and cursored
gameplay-owner approval gate. retirement next. Slices F/G remain behind their explicit ECS approval gate,
and Slice J remains behind its gameplay-owner approval gate.
Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2's Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2's
status hand selected-object availability and existing status hand selected-object availability and existing

View file

@ -1,6 +1,6 @@
# Modern Runtime Slice E — Cost-budgeted streaming and retirement # Modern Runtime Slice E — Cost-budgeted streaming and retirement
**Status:** active — E0/E1 complete; E2 explicit admission queues next **Status:** active — E0/E1/E2 complete; E3 quiesce and budgeted retirement next
**Program:** `docs/plans/2026-07-24-modern-runtime-architecture.md` **Program:** `docs/plans/2026-07-24-modern-runtime-architecture.md`
**Baseline:** Slice D closeout commit `66690805` **Baseline:** Slice D closeout commit `66690805`
**Behavior contract:** no visual-quality or retail-behavior reduction **Behavior contract:** no visual-quality or retail-behavior reduction
@ -255,6 +255,30 @@ retry, stale-generation, and deferred-compaction tests remain green.
- Keep current publication atomic until E4, but execute only work admitted by - Keep current publication atomic until E4, but execute only work admitted by
the typed meter. the typed meter.
**Complete 2026-07-24.** Production now consumes the worker channel through
one allocation-free `TryPeek`/`TryRead` source, prices the exact immutable
result before adoption, and admits it through the typed completion-count and
retained-CPU dimensions. Accepted work lives in reusable stable FIFOs for
destination, control, unload, Near, and Far classes. FIFO order is exact inside
each class, a blocked publication head does not reorder its tail, callback
appends cannot invalidate an active receipt, and exact-reference compaction
cannot conflate equal records or GUID/landblock reuse.
`_deferredApply`, `MaxDrainIterations`, direct priority hunting, and the unload
budget bypass are gone. Destination and unload work win queue order but consume
the same execution meter. One indivisible publication may make progress after
admission work, but that privilege is granted only once per frame and every
oversize/overrun remains named. Stale generations consume a bounded admission
without retaining their payload. The old completion-count quality setting now
selects a complete scaled time/count/byte/operation profile rather than acting
as a hidden second throttle.
Validation: 240 focused App streaming tests and 58 focused Core streamer/
controller tests passed; the complete App suite passed 3,656 / 3 skipped, the
complete solution passed 8,138 / 5 skipped, and the Release solution build was
clean. Physical connected and visual gates are intentionally deferred until
E6; the active desktop was RDP and is not valid performance evidence.
### E3 — Quiesce and budgeted retirement ### E3 — Quiesce and budgeted retirement
- Add destination-generation world quiesce. - Add destination-generation world quiesce.

View file

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

View file

@ -43,7 +43,7 @@ namespace AcDream.App.Streaming;
/// methods are thread-safe. /// methods are thread-safe.
/// </remarks> /// </remarks>
/// </summary> /// </summary>
public sealed class LandblockStreamer : IDisposable public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
{ {
/// <summary> /// <summary>
/// Default drain batch size. Tuned to cap GPU upload work the render /// 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 readonly object _inboxGate = new();
private Thread? _worker; private Thread? _worker;
private Exception? _workerFailure; private Exception? _workerFailure;
private int _completionBacklog;
private int _disposed; private int _disposed;
private readonly object _disposeGate = new(); private readonly object _disposeGate = new();
private bool _disposeCompleted; private bool _disposeCompleted;
@ -276,11 +277,37 @@ public sealed class LandblockStreamer : IDisposable
public IReadOnlyList<LandblockStreamResult> DrainCompletions(int maxBatchSize = DefaultDrainBatchSize) public IReadOnlyList<LandblockStreamResult> DrainCompletions(int maxBatchSize = DefaultDrainBatchSize)
{ {
var batch = new List<LandblockStreamResult>(maxBatchSize); 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); batch.Add(result);
}
return batch; 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() private void WorkerLoop()
{ {
var highPriority = new Queue<LandblockStreamJob>(); var highPriority = new Queue<LandblockStreamJob>();
@ -337,7 +364,7 @@ public sealed class LandblockStreamer : IDisposable
_workerFailure = ex; _workerFailure = ex;
_inbox.Writer.TryComplete(ex); _inbox.Writer.TryComplete(ex);
} }
_outbox.Writer.TryWrite(new LandblockStreamResult.WorkerCrashed(ex.ToString())); PublishResult(new LandblockStreamResult.WorkerCrashed(ex.ToString()));
} }
finally finally
{ {
@ -426,13 +453,13 @@ public sealed class LandblockStreamer : IDisposable
var build = _loadLandblock(load.Request); var build = _loadLandblock(load.Request);
if (build is null) if (build is null)
{ {
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed( PublishResult(new LandblockStreamResult.Failed(
load.LandblockId, "LandblockLoader.Load returned null", load.Generation)); load.LandblockId, "LandblockLoader.Load returned null", load.Generation));
break; break;
} }
if (build.Origin != load.Origin) if (build.Origin != load.Origin)
{ {
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed( PublishResult(new LandblockStreamResult.Failed(
load.LandblockId, load.LandblockId,
$"Landblock build origin {build.Origin} did not match request origin {load.Origin}", $"Landblock build origin {build.Origin} did not match request origin {load.Origin}",
load.Generation)); load.Generation));
@ -444,18 +471,18 @@ public sealed class LandblockStreamer : IDisposable
var promotedMesh = _buildMeshOrNull(load.LandblockId, lb); var promotedMesh = _buildMeshOrNull(load.LandblockId, lb);
if (promotedMesh is null) if (promotedMesh is null)
{ {
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed( PublishResult(new LandblockStreamResult.Failed(
load.LandblockId, "buildMeshOrNull returned null", load.Generation)); load.LandblockId, "buildMeshOrNull returned null", load.Generation));
break; break;
} }
_outbox.Writer.TryWrite(new LandblockStreamResult.Promoted( PublishResult(new LandblockStreamResult.Promoted(
load.LandblockId, build, promotedMesh, load.Generation)); load.LandblockId, build, promotedMesh, load.Generation));
break; break;
} }
var mesh = _buildMeshOrNull(load.LandblockId, lb); var mesh = _buildMeshOrNull(load.LandblockId, lb);
if (mesh is null) if (mesh is null)
{ {
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed( PublishResult(new LandblockStreamResult.Failed(
load.LandblockId, "buildMeshOrNull returned null", load.Generation)); load.LandblockId, "buildMeshOrNull returned null", load.Generation));
break; break;
} }
@ -485,18 +512,18 @@ public sealed class LandblockStreamer : IDisposable
PhysicsDatBundle.Empty); PhysicsDatBundle.Empty);
build = new LandblockBuild(lb, Origin: build.Origin); build = new LandblockBuild(lb, Origin: build.Origin);
} }
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded( PublishResult(new LandblockStreamResult.Loaded(
load.LandblockId, tier, build, mesh, load.Generation)); load.LandblockId, tier, build, mesh, load.Generation));
} }
catch (Exception ex) catch (Exception ex)
{ {
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed( PublishResult(new LandblockStreamResult.Failed(
load.LandblockId, ex.ToString(), load.Generation)); load.LandblockId, ex.ToString(), load.Generation));
} }
break; break;
case LandblockStreamJob.Unload unload: case LandblockStreamJob.Unload unload:
_outbox.Writer.TryWrite(new LandblockStreamResult.Unloaded( PublishResult(new LandblockStreamResult.Unloaded(
unload.LandblockId, unload.LandblockId,
unload.Generation)); unload.Generation));
break; 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> /// </summary>
public sealed class StreamingController : IStreamingFrameBackend public sealed class StreamingController : IStreamingFrameBackend
{ {
private readonly record struct DeferredCompletion(
LandblockStreamResult Result,
StreamingWorkCost Cost,
long EnqueuedTimestamp);
private sealed class OriginRecenterRetirement private sealed class OriginRecenterRetirement
{ {
public bool RadiiConverged; public bool RadiiConverged;
public bool GenerationAdvanced; public bool GenerationAdvanced;
public bool PendingLoadsCleared; public bool PendingLoadsCleared;
public bool DeferredApplyCleared; public bool CompletionQueueCleared;
public bool RegionCleared; public bool RegionCleared;
public List<uint>? ResidentIds; public List<uint>? ResidentIds;
public int RetirementCursor; public int RetirementCursor;
@ -41,13 +36,18 @@ public sealed class StreamingController : IStreamingFrameBackend
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad; private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
private readonly Action<uint, ulong> _enqueueUnload; private readonly Action<uint, ulong> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions; private readonly ILandblockCompletionSource _completionSource;
private readonly Action? _clearPendingLoads; private readonly Action? _clearPendingLoads;
private readonly LandblockPresentationPipeline _presentation; 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 StreamingWorkMeter? _activeWorkMeter;
private StreamingWorkMeterSnapshot _lastWorkMeter; private StreamingWorkMeterSnapshot _lastWorkMeter;
private long _deferredAdoptedCpuBytes; private readonly StreamingCompletionQueue _completionQueue = new();
private long _nextCompletionSequence;
private int _legacyCompletionProfile = 4;
private readonly GpuWorldState _state; private readonly GpuWorldState _state;
private StreamingRegion? _region; private StreamingRegion? _region;
@ -88,34 +88,28 @@ public sealed class StreamingController : IStreamingFrameBackend
public int FarRadius { get; private set; } public int FarRadius { get; private set; }
/// <summary> /// <summary>
/// Cap on completions drained per <see cref="Tick"/> call. The cap is /// Compatibility quality selector for callers that still express the
/// the GPU upload budget for one frame: terrain mesh + per-entity GfxObj /// streaming profile as a completion count. Four preserves the configured
/// sub-mesh uploads + texture uploads for one landblock take a few ms; /// High profile; other positive values scale every typed time/count/byte
/// applying 25 of them in a single frame produces a memory spike /// dimension together. It is not an independent execution throttle.
/// (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>
/// </summary> /// </summary>
public int MaxCompletionsPerFrame { get; set; } = 4; public int MaxCompletionsPerFrame
{
get => _legacyCompletionProfile;
set
{
StreamingWorkBudgetOptions profile =
_configuredWorkBudgetOptions
.ScaleForLegacyCompletionCount(value);
_legacyCompletionProfile = value;
_workBudget = profile.ToBudget();
}
}
/// <summary> /// <summary>
/// #138: the teleport destination landblock id. When non-zero, /// #138: the teleport destination landblock id. Loaded or promoted results
/// <see cref="DrainAndApply"/> applies this landblock's /// inside its priority ring enter the destination FIFO, ahead of ordinary
/// <see cref="LandblockStreamResult.Loaded"/> or /// Near/Far work, but still consume the same typed frame budget.
/// <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 /// <para>Set by <c>GameWindow</c> when a teleport starts; reset to 0
/// once the destination landblock has been applied (or when the /// once the destination landblock has been applied (or when the
@ -124,32 +118,39 @@ public sealed class StreamingController : IStreamingFrameBackend
public uint PriorityLandblockId { get; set; } public uint PriorityLandblockId { get; set; }
/// <summary> /// <summary>
/// 2026-06-22: the radius (in landblocks, Chebyshev) around /// Radius in landblocks (Chebyshev distance) around
/// <see cref="PriorityLandblockId"/> that <see cref="DrainAndApply"/> eager-applies /// <see cref="PriorityLandblockId"/> classified as destination work.
/// ahead of the per-frame budget. 0 (default) = only the single center landblock — the /// Zero means only the center. This changes queue priority, never budget
/// original priority behaviour. A teleport sets this to the near ring so the player's /// enforcement or atomic publication semantics.
/// 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.
/// </summary> /// </summary>
public int PriorityRadius { get; set; } public int PriorityRadius { get; set; }
// [FRAME-DIAG] (read by GameWindow's ACDREAM_WB_DIAG rollup): the standing // [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 // accepted completion backlog. A non-zero value during/after a teleport is
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface. // the typed-budget publication tail. Removable compatibility probe surface.
public int DeferredApplyBacklog => _deferredApply.Count; public int DeferredApplyBacklog => _completionQueue.Count;
public int PendingRetirementCount => _presentation.PendingRetirementCount; public int PendingRetirementCount => _presentation.PendingRetirementCount;
public StreamingWorkDiagnostics WorkDiagnostics => new( public StreamingWorkDiagnostics WorkDiagnostics
_lastWorkMeter, {
_deferredApply.Count, get
_deferredAdoptedCpuBytes, {
OldestDeferredAgeMilliseconds(), StreamingCompletionQueueSnapshot queued =
_presentation.PendingPublicationCount, _completionQueue.CaptureSnapshot();
_presentation.PendingRetirementCount); 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; internal bool IsCollapsedToDungeon => _collapsed;
/// <summary> /// <summary>
@ -195,9 +196,6 @@ public sealed class StreamingController : IStreamingFrameBackend
public int FullWindowRetirementCount { get; private set; } public int FullWindowRetirementCount { get; private set; }
public int LastFullWindowRetirementLandblockCount { 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> /// <summary>
/// Internal compatibility seam for hermetic controller policy tests. Live /// Internal compatibility seam for hermetic controller policy tests. Live
/// composition cannot supply presentation callbacks; it must use the /// composition cannot supply presentation callbacks; it must use the
@ -216,7 +214,8 @@ public sealed class StreamingController : IStreamingFrameBackend
Action? clearPendingLoads = null, Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null, Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null, Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null) LandblockRetirementCoordinator? retirementCoordinator = null,
StreamingWorkBudgetOptions? workBudgetOptions = null)
: this( : this(
enqueueLoad, enqueueLoad,
enqueueUnload, enqueueUnload,
@ -232,7 +231,8 @@ public sealed class StreamingController : IStreamingFrameBackend
retirementCoordinator, retirementCoordinator,
removeTerrain, removeTerrain,
demoteNearLayer), demoteNearLayer),
clearPendingLoads) clearPendingLoads,
workBudgetOptions)
{ {
} }
@ -241,7 +241,7 @@ public sealed class StreamingController : IStreamingFrameBackend
/// retirement owner, so no legacy publication delegate can be supplied or /// retirement owner, so no legacy publication delegate can be supplied or
/// silently ignored. /// silently ignored.
/// </summary> /// </summary>
public StreamingController( internal StreamingController(
Action<uint, LandblockStreamJobKind, ulong> enqueueLoad, Action<uint, LandblockStreamJobKind, ulong> enqueueLoad,
Action<uint, ulong> enqueueUnload, Action<uint, ulong> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions, Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
@ -251,16 +251,47 @@ public sealed class StreamingController : IStreamingFrameBackend
LandblockPresentationPipeline presentationPipeline, LandblockPresentationPipeline presentationPipeline,
Action? clearPendingLoads = null, Action? clearPendingLoads = null,
StreamingWorkBudgetOptions? workBudgetOptions = 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; _enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload; _enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions; _completionSource = completionSource
?? throw new ArgumentNullException(nameof(completionSource));
_clearPendingLoads = clearPendingLoads; _clearPendingLoads = clearPendingLoads;
_state = state; _state = state;
_presentation = presentationPipeline _presentation = presentationPipeline
?? throw new ArgumentNullException(nameof(presentationPipeline)); ?? throw new ArgumentNullException(nameof(presentationPipeline));
_workBudget = (workBudgetOptions ?? StreamingWorkBudgetOptions.Default) _isPublicationBlockedByRetirement =
.ToBudget(); IsPublicationBlockedByRetirement;
_configuredWorkBudgetOptions =
workBudgetOptions ?? StreamingWorkBudgetOptions.Default;
_workBudget = _configuredWorkBudgetOptions.ToBudget();
if (!_presentation.MatchesState(_state)) if (!_presentation.MatchesState(_state))
{ {
throw new ArgumentException( throw new ArgumentException(
@ -400,7 +431,8 @@ public sealed class StreamingController : IStreamingFrameBackend
Action? clearPendingLoads = null, Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null, Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null, Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null) LandblockRetirementCoordinator? retirementCoordinator = null,
StreamingWorkBudgetOptions? workBudgetOptions = null)
: this( : this(
(id, kind, _) => enqueueLoad(id, kind), (id, kind, _) => enqueueLoad(id, kind),
(id, _) => enqueueUnload(id), (id, _) => enqueueUnload(id),
@ -414,7 +446,8 @@ public sealed class StreamingController : IStreamingFrameBackend
clearPendingLoads, clearPendingLoads,
onLandblockLoaded, onLandblockLoaded,
ensureEnvCellMeshes, ensureEnvCellMeshes,
retirementCoordinator) retirementCoordinator,
workBudgetOptions)
{ {
} }
@ -429,48 +462,47 @@ public sealed class StreamingController : IStreamingFrameBackend
_presentation.BeginNearLayerRetirement(canonical); _presentation.BeginNearLayerRetirement(canonical);
} }
private void AdvanceGeneration() private bool AdvanceGeneration()
{ {
ConvergePendingPublications(); if (!ConvergePendingPublications())
return false;
_generation = unchecked(_generation + 1); _generation = unchecked(_generation + 1);
return true;
} }
private void ConvergePendingPublications() private bool ConvergePendingPublications()
{ {
IReadOnlyList<LandblockStreamResult> pending = IReadOnlyList<LandblockStreamResult> pending =
_presentation.GetPendingPublicationResults(); _presentation.GetPendingPublicationResults();
if (pending.Count == 0) if (pending.Count == 0)
return; return true;
try try
{ {
bool progressed = false;
for (int i = 0; i < pending.Count; i++) for (int i = 0; i < pending.Count; i++)
{ {
LandblockStreamResult result = pending[i]; LandblockStreamResult result = pending[i];
ObserveResultOperation( if (!TryObserveResultOperation(
result, result,
"publication-retry", "publication-retry",
() => _presentation.ResumePublication(result)); () => _presentation.ResumePublication(result),
ensureProgress: !progressed))
{
return false;
}
progressed = true;
} }
return true;
} }
finally finally
{ {
// A completed staged result can still be present in the deferred // A completed staged result can still be present in the deferred
// FIFO that originally retained it. Remove only those exact // FIFO that originally retained it. Remove only those exact
// objects; unrelated accepted completions keep their order. // objects; unrelated accepted completions keep their order.
_deferredApply.RemoveAll(entry => _completionQueue.RemoveResults(
{ pending,
for (int i = 0; i < pending.Count; i++) result => !_presentation.HasPendingPublication(result));
{
if (ReferenceEquals(entry.Result, pending[i]))
{
return !_presentation.HasPendingPublication(
entry.Result);
}
}
return false;
});
RecalculateDeferredAdoptedBytes();
} }
} }
@ -518,7 +550,8 @@ public sealed class StreamingController : IStreamingFrameBackend
// Complete an admitted publication before this frame can mutate its // Complete an admitted publication before this frame can mutate its
// desired tier or spatial residence. A later recenter may then demote // desired tier or spatial residence. A later recenter may then demote
// or retire the fully known owner set through the normal ledger. // or retire the fully known owner set through the normal ledger.
ConvergePendingPublications(); if (!ConvergePendingPublications())
return;
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy); uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
@ -574,7 +607,8 @@ public sealed class StreamingController : IStreamingFrameBackend
if (!pending.GenerationAdvanced) if (!pending.GenerationAdvanced)
{ {
AdvanceGeneration(); if (!AdvanceGeneration())
return;
pending.GenerationAdvanced = true; pending.GenerationAdvanced = true;
} }
@ -625,7 +659,7 @@ public sealed class StreamingController : IStreamingFrameBackend
_region = pending.Region; _region = pending.Region;
NearRadius = pending.NearRadius; NearRadius = pending.NearRadius;
FarRadius = pending.FarRadius; FarRadius = pending.FarRadius;
ClearDeferred(); _completionQueue.Clear();
_pendingRadiiReconfiguration = null; _pendingRadiiReconfiguration = null;
} }
} }
@ -762,11 +796,12 @@ public sealed class StreamingController : IStreamingFrameBackend
bool logTransition = !_collapsed || _collapsedCenter != centerId; bool logTransition = !_collapsed || _collapsedCenter != centerId;
if (logTransition) if (logTransition)
Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}"); Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}");
AdvanceGeneration(); if (!AdvanceGeneration())
return;
_collapsed = true; _collapsed = true;
_collapsedCenter = centerId; _collapsedCenter = centerId;
_clearPendingLoads?.Invoke(); _clearPendingLoads?.Invoke();
ClearDeferred(); _completionQueue.Clear();
_region = null; _region = null;
foreach (var id in _state.LoadedLandblockIds) foreach (var id in _state.LoadedLandblockIds)
@ -811,7 +846,7 @@ public sealed class StreamingController : IStreamingFrameBackend
/// <summary> /// <summary>
/// True when <paramref name="id"/> is the priority center or within /// True when <paramref name="id"/> is the priority center or within
/// <see cref="PriorityRadius"/> landblocks of it (Chebyshev) — i.e. inside the teleport /// <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 /// this reduces to an exact match on <see cref="PriorityLandblockId"/> (the original
/// single-landblock priority behaviour). /// single-landblock priority behaviour).
/// </summary> /// </summary>
@ -829,7 +864,8 @@ public sealed class StreamingController : IStreamingFrameBackend
Console.WriteLine( Console.WriteLine(
$"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " + $"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " +
$"(was collapsed on 0x{_collapsedCenter:X8})"); $"(was collapsed on 0x{_collapsedCenter:X8})");
AdvanceGeneration(); if (!AdvanceGeneration())
return;
_collapsed = false; _collapsed = false;
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius); var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
@ -1027,7 +1063,8 @@ public sealed class StreamingController : IStreamingFrameBackend
} }
if (!transaction.GenerationAdvanced) if (!transaction.GenerationAdvanced)
{ {
AdvanceGeneration(); if (!AdvanceGeneration())
return false;
transaction.GenerationAdvanced = true; transaction.GenerationAdvanced = true;
} }
if (!transaction.PendingLoadsCleared) if (!transaction.PendingLoadsCleared)
@ -1045,10 +1082,10 @@ public sealed class StreamingController : IStreamingFrameBackend
return false; return false;
} }
} }
if (!transaction.DeferredApplyCleared) if (!transaction.CompletionQueueCleared)
{ {
ClearDeferred(); _completionQueue.Clear();
transaction.DeferredApplyCleared = true; transaction.CompletionQueueCleared = true;
} }
if (!transaction.RegionCleared) if (!transaction.RegionCleared)
{ {
@ -1103,10 +1140,11 @@ public sealed class StreamingController : IStreamingFrameBackend
private void BeginFullWindowRetirement() private void BeginFullWindowRetirement()
{ {
AdvanceGeneration(); if (!AdvanceGeneration())
return;
_collapsed = false; _collapsed = false;
_clearPendingLoads?.Invoke(); _clearPendingLoads?.Invoke();
ClearDeferred(); _completionQueue.Clear();
// Commit the old region boundary before any presentation owner is // Commit the old region boundary before any presentation owner is
// advanced. New same-id publications are fenced by the presentation // advanced. New same-id publications are fenced by the presentation
// pipeline's retryable retirement ledger. // pipeline's retryable retirement ledger.
@ -1120,164 +1158,171 @@ public sealed class StreamingController : IStreamingFrameBackend
} }
/// <summary> /// <summary>
/// Apply streamed completions for this frame. LOADS (terrain mesh GPU uploads) are the /// Adopts immutable worker results and advances explicit priority FIFOs
/// expensive part, so they are metered at <see cref="MaxCompletionsPerFrame"/> to avoid a /// under the one typed frame meter. Destination and unload work win queue
/// GPU-upload spike; the overflow buffers in <see cref="_deferredApply"/> and drains over /// order but no class bypasses time, bytes, entities, uploads, or retire
/// subsequent frames. UNLOADS are cheap (they free GPU buffers — no upload) and are applied /// operation limits.
/// 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.
/// </summary> /// </summary>
private void DrainAndApply() private void DrainAndApply()
{ {
// --- Step 1: drain the outbox in bounded chunks. Apply unloads + priority near-ring StreamingWorkMeter meter = _activeWorkMeter
// loads immediately; defer every other (budget-metered) load. Draining the whole ?? throw new InvalidOperationException(
// outbox each frame (bounded by MaxDrainIterations) is what lets unloads flush "Completion scheduling requires an active frame meter.");
// 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
}
}
// --- Step 2: apply the deferred LOAD backlog at the per-frame budget (FIFO, so AdmitCompletions(meter);
// earlier-queued landblocks win). Caps GPU upload per frame so a big diff doesn't bool executed = false;
// spike. _deferredApply now only ever holds loads — unloads were applied in step 1. while (_completionQueue.TryPeekNext(
int budget = MaxCompletionsPerFrame; _isPublicationBlockedByRetirement,
int applied = 0; out StreamingQueuedCompletion? completion))
int read = 0;
int write = 0;
try
{ {
while (applied < budget && read < _deferredApply.Count) StreamingQueuedCompletion work = completion
{ ?? throw new InvalidOperationException(
DeferredCompletion entry = _deferredApply[read]; "The completion queue returned a null head.");
LandblockStreamResult result = entry.Result; StreamingWorkCost executionCost =
if (IsPublicationBlockedByRetirement(result)) IsStaleGeneration(work.Result)
{ ? default
if (write != read) : work.ExecutionCost;
_deferredApply[write] = entry; StreamingWorkAdmission admission = meter.TryReserve(
write++; executionCost,
read++; $"execute-{work.Priority}",
continue; ensureProgress: !executed);
} if (admission == StreamingWorkAdmission.Yielded)
break;
// Advance read only after the apply commits. If it throws, the try
// finally block removes prior committed entries but retains this {
// exact result and the untouched tail for retry. ApplyResult(work.Result);
try _completionQueue.RemoveHead(work);
{ meter.Complete();
ApplyResultObserved( executed = true;
result, }
"deferred-publication", catch
entry.Cost); {
read++; // Concrete publication failures retain their exact owner
applied++; // receipt and queue head. A compatibility callback without a
} // receipt cannot safely replay, so consume only that result.
catch if (!_presentation.HasPendingPublication(work.Result))
{ _completionQueue.RemoveHead(work);
// A concrete suffix-stage failure keeps its pipeline meter.Fail();
// receipt and must remain at this exact FIFO position. The throw;
// 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;
}
} }
}
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( private void AdmitCompletions(StreamingWorkMeter meter)
LandblockStreamResult result, {
string stage, while (_completionSource.TryPeek(out LandblockStreamResult? peeked))
StreamingWorkCost? knownCost = null) => {
ObserveResultOperation( LandblockStreamResult result = peeked
result, ?? throw new InvalidOperationException(
stage, "The completion source returned a null peek.");
() => ApplyResult(result), bool stale = IsStaleGeneration(result)
knownCost); && !_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, LandblockStreamResult result,
string stage, string stage,
Action operation, Action operation,
StreamingWorkCost? knownCost = null) StreamingWorkCost? knownCost = null,
bool ensureProgress = false)
{ {
StreamingWorkMeter? meter = _activeWorkMeter; StreamingWorkMeter? meter = _activeWorkMeter;
if (meter is null) if (meter is null)
{ {
operation(); operation();
return; return true;
} }
StreamingWorkCost cost = knownCost StreamingWorkCost aggregate = knownCost
?? LandblockStreamResultCost.Estimate(result).Work; ?? 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) if (admission == StreamingWorkAdmission.Yielded)
meter.ForceReserveObserved(cost, stage); return false;
try try
{ {
operation(); operation();
meter.Complete(); meter.Complete();
return true;
} }
catch 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> /// <summary>
/// Apply a single <see cref="LandblockStreamResult"/> with the full side- /// Apply a single <see cref="LandblockStreamResult"/> with the full side-
/// effects: terrain upload, GPU state, and the re-hydration callback. /// effects: terrain upload, GPU state, and the re-hydration callback.
/// Extracted from the inline switch in the original <c>DrainAndApply</c> /// All priority queues route through this one publication path.
/// so both the priority-hunt path and the normal drain path share it.
/// </summary> /// </summary>
private void ApplyResult(LandblockStreamResult result) private void ApplyResult(LandblockStreamResult result)
{ {

View file

@ -159,7 +159,13 @@ public readonly record struct StreamingWorkDiagnostics(
long DeferredAdoptedCpuBytes, long DeferredAdoptedCpuBytes,
double OldestDeferredAgeMilliseconds, double OldestDeferredAgeMilliseconds,
int PendingPublications, int PendingPublications,
int PendingRetirements); int PendingRetirements,
int WorkerCompletionBacklog,
int DestinationBacklog,
int ControlBacklog,
int UnloadBacklog,
int NearBacklog,
int FarBacklog);
/// <summary> /// <summary>
/// Single-thread, frame-scoped admission meter. Callers reserve a known cost /// Single-thread, frame-scoped admission meter. Callers reserve a known cost
@ -180,6 +186,7 @@ public sealed class StreamingWorkMeter
private int _failures; private int _failures;
private bool _frameOverrunRecorded; private bool _frameOverrunRecorded;
private bool _reservationActive; private bool _reservationActive;
private bool _ensuredProgressGranted;
private string? _activeStage; private string? _activeStage;
private string? _lastStage; private string? _lastStage;
private StreamingWorkLimit _lastLimit; private StreamingWorkLimit _lastLimit;
@ -207,7 +214,8 @@ public sealed class StreamingWorkMeter
public StreamingWorkAdmission TryReserve( public StreamingWorkAdmission TryReserve(
StreamingWorkCost cost, StreamingWorkCost cost,
string stage) string stage,
bool ensureProgress = false)
{ {
cost.Validate(); cost.Validate();
ArgumentException.ThrowIfNullOrWhiteSpace(stage); ArgumentException.ThrowIfNullOrWhiteSpace(stage);
@ -216,7 +224,11 @@ public sealed class StreamingWorkMeter
"The prior streaming operation has not completed."); "The prior streaming operation has not completed.");
StreamingWorkLimit limit = FindLimit(cost); StreamingWorkLimit limit = FindLimit(cost);
if (limit != StreamingWorkLimit.None && _operations != 0) bool grantEnsuredProgress =
ensureProgress && !_ensuredProgressGranted;
if (limit != StreamingWorkLimit.None
&& _operations != 0
&& !grantEnsuredProgress)
{ {
_yields++; _yields++;
_lastStage = stage; _lastStage = stage;
@ -228,14 +240,21 @@ public sealed class StreamingWorkMeter
_operations++; _operations++;
_reservationActive = true; _reservationActive = true;
_activeStage = stage; _activeStage = stage;
_lastStage = stage; if (ensureProgress)
_lastLimit = limit; _ensuredProgressGranted = true;
if (limit != StreamingWorkLimit.None) if (limit != StreamingWorkLimit.None)
{ {
_lastStage = stage;
_lastLimit = limit;
_oversizedProgress++; _oversizedProgress++;
return StreamingWorkAdmission.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; return StreamingWorkAdmission.Admitted;
} }

View file

@ -65,6 +65,31 @@ public sealed record StreamingWorkBudgetOptions(
MaxGlRetireOperations, MaxGlRetireOperations,
DestinationReserveFraction); 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) => private static double ParsePositiveDouble(string? value, double fallback) =>
double.TryParse( double.TryParse(
value, value,
@ -118,4 +143,22 @@ public sealed record StreamingWorkBudgetOptions(
return percent / 100f; 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));
}
} }

View file

@ -1004,7 +1004,15 @@ public sealed class LandblockPresentationPipelineTests
state, state,
nearRadius: 0, nearRadius: 0,
farRadius: 2, farRadius: 2,
removeTerrain: id => calls.Add($"unload:{id:X8}")); removeTerrain: id => calls.Add($"unload:{id:X8}"),
workBudgetOptions: new StreamingWorkBudgetOptions(
MaxUpdateMilliseconds: 100,
MaxCompletionAdmissions: 3,
MaxAdoptedCpuBytes: 1_000_000,
MaxEntityOperations: 1_000,
MaxGpuUploadBytes: 1_000_000,
MaxGlRetireOperations: 1_000,
DestinationReserveFraction: 0.75f));
controller.PriorityLandblockId = priorityId; controller.PriorityLandblockId = priorityId;
Assert.Throws<InvalidOperationException>(() => controller.Tick(0x70, 0x70)); Assert.Throws<InvalidOperationException>(() => controller.Tick(0x70, 0x70));
@ -1021,8 +1029,8 @@ public sealed class LandblockPresentationPipelineTests
Assert.Equal( Assert.Equal(
[ [
$"publish:{priorityId:X8}", $"publish:{priorityId:X8}",
$"publish:{laterId:X8}",
$"unload:{unloadId:X8}", $"unload:{unloadId:X8}",
$"publish:{laterId:X8}",
], ],
calls); calls);
} }

View file

@ -0,0 +1,207 @@
using System.Diagnostics;
using AcDream.App.Streaming;
namespace AcDream.App.Tests.Streaming;
public sealed class StreamingCompletionQueueTests
{
[Fact]
public void PrioritySelectionIsStableFifoAndSkipsOnlyBlockedHead()
{
var queue = new StreamingCompletionQueue();
StreamingQueuedCompletion farFirst = Completion(
new LandblockStreamResult.Failed(1, "far-first"),
StreamingCompletionPriority.Far,
sequence: 1);
StreamingQueuedCompletion nearBlocked = Completion(
new LandblockStreamResult.Failed(2, "near-blocked"),
StreamingCompletionPriority.Near,
sequence: 2);
StreamingQueuedCompletion nearTail = Completion(
new LandblockStreamResult.Failed(3, "near-tail"),
StreamingCompletionPriority.Near,
sequence: 3);
StreamingQueuedCompletion destinationFirst = Completion(
new LandblockStreamResult.Failed(4, "destination-first"),
StreamingCompletionPriority.Destination,
sequence: 4);
StreamingQueuedCompletion destinationTail = Completion(
new LandblockStreamResult.Failed(5, "destination-tail"),
StreamingCompletionPriority.Destination,
sequence: 5);
queue.Enqueue(farFirst);
queue.Enqueue(nearBlocked);
queue.Enqueue(nearTail);
queue.Enqueue(destinationFirst);
queue.Enqueue(destinationTail);
Assert.True(queue.TryPeekNext(
static _ => false,
out StreamingQueuedCompletion? selected));
Assert.Equal(destinationFirst.Sequence, selected?.Sequence);
Assert.Same(destinationFirst.Result, selected?.Result);
queue.RemoveHead(destinationFirst);
Assert.True(queue.TryPeekNext(
result => ReferenceEquals(result, destinationTail.Result)
|| ReferenceEquals(result, nearBlocked.Result),
out selected));
Assert.Equal(farFirst.Sequence, selected?.Sequence);
Assert.Same(farFirst.Result, selected?.Result);
Assert.Throws<InvalidOperationException>(() =>
queue.RemoveHead(nearTail));
Assert.True(queue.TryPeekNext(
static _ => false,
out selected));
Assert.Equal(destinationTail.Sequence, selected?.Sequence);
Assert.Same(destinationTail.Result, selected?.Result);
queue.RemoveHead(destinationTail);
Assert.True(queue.TryPeekNext(
static _ => false,
out selected));
Assert.Equal(nearBlocked.Sequence, selected?.Sequence);
Assert.Same(nearBlocked.Result, selected?.Result);
}
[Fact]
public void RetainedBytesAndPriorityFactsTrackExactQueueLifetime()
{
var queue = new StreamingCompletionQueue();
StreamingQueuedCompletion destination = Completion(
new LandblockStreamResult.Failed(10, "destination"),
StreamingCompletionPriority.Destination,
sequence: 1,
adoptedBytes: 12);
StreamingQueuedCompletion unload = Completion(
new LandblockStreamResult.Unloaded(11),
StreamingCompletionPriority.Unload,
sequence: 2,
adoptedBytes: 20);
StreamingQueuedCompletion near = Completion(
new LandblockStreamResult.Failed(12, "near"),
StreamingCompletionPriority.Near,
sequence: 3,
adoptedBytes: 30);
queue.Enqueue(destination);
queue.Enqueue(unload);
queue.Enqueue(near);
StreamingCompletionQueueSnapshot snapshot = queue.CaptureSnapshot();
Assert.Equal(3, snapshot.Count);
Assert.Equal(62, snapshot.RetainedCpuBytes);
Assert.Equal(1, snapshot.Destination);
Assert.Equal(1, snapshot.Unload);
Assert.Equal(1, snapshot.Near);
Assert.True(snapshot.OldestAgeMilliseconds >= 0);
queue.RemoveHead(destination);
snapshot = queue.CaptureSnapshot();
Assert.Equal(2, snapshot.Count);
Assert.Equal(50, snapshot.RetainedCpuBytes);
queue.Clear();
snapshot = queue.CaptureSnapshot();
Assert.Equal(0, snapshot.Count);
Assert.Equal(0, snapshot.RetainedCpuBytes);
Assert.Equal(0, snapshot.OldestAgeMilliseconds);
}
[Fact]
public void ExactReferenceRemovalDoesNotConflateEqualRecords()
{
var queue = new StreamingCompletionQueue();
var firstResult =
new LandblockStreamResult.Failed(20, "same", Generation: 7);
var equalButDistinctResult =
new LandblockStreamResult.Failed(20, "same", Generation: 7);
StreamingQueuedCompletion first = Completion(
firstResult,
StreamingCompletionPriority.Control,
sequence: 1,
adoptedBytes: 11);
StreamingQueuedCompletion second = Completion(
equalButDistinctResult,
StreamingCompletionPriority.Control,
sequence: 2,
adoptedBytes: 13);
queue.Enqueue(first);
queue.Enqueue(second);
int removed = queue.RemoveResults(
[equalButDistinctResult],
static _ => true);
Assert.Equal(1, removed);
Assert.Equal(1, queue.Count);
Assert.Equal(11, queue.RetainedCpuBytes);
Assert.True(queue.TryPeekNext(
static _ => false,
out StreamingQueuedCompletion? selected));
Assert.Equal(first.Sequence, selected?.Sequence);
Assert.Same(first.Result, selected?.Result);
}
[Fact]
public void CallbackAppendNeverInvalidatesActiveHead()
{
var queue = new StreamingCompletionQueue();
StreamingQueuedCompletion active = Completion(
new LandblockStreamResult.Failed(30, "active"),
StreamingCompletionPriority.Near,
sequence: 1);
StreamingQueuedCompletion appended = Completion(
new LandblockStreamResult.Failed(31, "appended"),
StreamingCompletionPriority.Near,
sequence: 2);
queue.Enqueue(active);
Assert.True(queue.TryPeekNext(
static _ => false,
out StreamingQueuedCompletion? selected));
Assert.Equal(active.Sequence, selected?.Sequence);
Assert.Same(active.Result, selected?.Result);
queue.Enqueue(appended);
queue.RemoveHead(active);
Assert.True(queue.TryPeekNext(
static _ => false,
out selected));
Assert.Equal(appended.Sequence, selected?.Sequence);
Assert.Same(appended.Result, selected?.Result);
}
private static StreamingQueuedCompletion Completion(
LandblockStreamResult result,
StreamingCompletionPriority priority,
long sequence,
long adoptedBytes = 0)
{
var estimate = new LandblockStreamCostEstimate(
Work: new StreamingWorkCost(
CompletionAdmissions: 1,
AdoptedCpuBytes: adoptedBytes),
TerrainPayloadBytes: 0,
Entities: 0,
MeshReferences: 0,
VisibilityCells: 0,
EnvCellShells: 0,
PortalConnections: 0,
PortalPolygonVertices: 0,
PhysicsEnvCells: 0,
PhysicsEnvironments: 0,
PhysicsSetups: 0,
PhysicsGfxObjects: 0);
return new StreamingQueuedCompletion(
result,
estimate,
priority,
result.Generation,
sequence,
Stopwatch.GetTimestamp());
}
}

View file

@ -83,6 +83,40 @@ public sealed class StreamingWorkBudgetTests
snapshot.LastLimit); snapshot.LastLimit);
} }
[Fact]
public void EnsureProgressCanCrossTheLimitOnlyOncePerFrame()
{
var meter = new StreamingWorkMeter(
Budget(cpuBytes: 1),
static () => 0,
timestampFrequency: 1_000);
Assert.Equal(
StreamingWorkAdmission.Admitted,
meter.TryReserve(
new StreamingWorkCost(CompletionAdmissions: 1),
"admission"));
meter.Complete();
Assert.Equal(
StreamingWorkAdmission.Admitted,
meter.TryReserve(
new StreamingWorkCost(AdoptedCpuBytes: 1),
"first-execution",
ensureProgress: true));
meter.Complete();
Assert.Equal(
StreamingWorkAdmission.Yielded,
meter.TryReserve(
new StreamingWorkCost(AdoptedCpuBytes: 1),
"second-execution",
ensureProgress: true));
Assert.Equal(1, meter.Snapshot.YieldCount);
Assert.Equal(
StreamingWorkLimit.AdoptedCpuBytes,
meter.Snapshot.LastLimit);
}
[Fact] [Fact]
public void MeterUsesInjectedMonotonicClockAndRecordsOverrunAndFailure() public void MeterUsesInjectedMonotonicClockAndRecordsOverrunAndFailure()
{ {
@ -129,6 +163,64 @@ public sealed class StreamingWorkBudgetTests
timestampFrequency: 1_000)); timestampFrequency: 1_000));
} }
[Theory]
[InlineData(2, 1.0, 2, 50, 4, 50, 2)]
[InlineData(4, 2.0, 4, 100, 8, 100, 4)]
[InlineData(6, 3.0, 6, 150, 12, 150, 6)]
public void LegacyCompletionSelectorScalesTheWholeProfile(
int count,
double milliseconds,
int admissions,
long cpuBytes,
int entityOperations,
long gpuBytes,
int retireOperations)
{
var options = new StreamingWorkBudgetOptions(
MaxUpdateMilliseconds: 2,
MaxCompletionAdmissions: 4,
MaxAdoptedCpuBytes: 100,
MaxEntityOperations: 8,
MaxGpuUploadBytes: 100,
MaxGlRetireOperations: 4,
DestinationReserveFraction: 0.75f);
StreamingWorkBudgetOptions scaled =
options.ScaleForLegacyCompletionCount(count);
Assert.Equal(milliseconds, scaled.MaxUpdateMilliseconds);
Assert.Equal(admissions, scaled.MaxCompletionAdmissions);
Assert.Equal(cpuBytes, scaled.MaxAdoptedCpuBytes);
Assert.Equal(entityOperations, scaled.MaxEntityOperations);
Assert.Equal(gpuBytes, scaled.MaxGpuUploadBytes);
Assert.Equal(retireOperations, scaled.MaxGlRetireOperations);
Assert.Equal(0.75f, scaled.DestinationReserveFraction);
}
[Fact]
public void LegacyCompletionSelectorRejectsZeroAndSaturatesHugeProfiles()
{
var options = new StreamingWorkBudgetOptions(
MaxUpdateMilliseconds: 2,
MaxCompletionAdmissions: int.MaxValue,
MaxAdoptedCpuBytes: long.MaxValue,
MaxEntityOperations: int.MaxValue,
MaxGpuUploadBytes: long.MaxValue,
MaxGlRetireOperations: int.MaxValue,
DestinationReserveFraction: 0.75f);
Assert.Throws<ArgumentOutOfRangeException>(() =>
options.ScaleForLegacyCompletionCount(0));
StreamingWorkBudgetOptions scaled =
options.ScaleForLegacyCompletionCount(int.MaxValue);
Assert.Equal(int.MaxValue, scaled.MaxCompletionAdmissions);
Assert.Equal(long.MaxValue, scaled.MaxAdoptedCpuBytes);
Assert.Equal(int.MaxValue, scaled.MaxEntityOperations);
Assert.Equal(long.MaxValue, scaled.MaxGpuUploadBytes);
Assert.Equal(int.MaxValue, scaled.MaxGlRetireOperations);
}
[Fact] [Fact]
public void CompletionCostChargesExactArraysAndDeterministicLogicalEntries() public void CompletionCostChargesExactArraysAndDeterministicLogicalEntries()
{ {
@ -239,10 +331,15 @@ public sealed class StreamingWorkBudgetTests
applyTerrain: static (_, _) => { }, applyTerrain: static (_, _) => { },
state: new GpuWorldState(), state: new GpuWorldState(),
nearRadius: 1, nearRadius: 1,
farRadius: 1) farRadius: 1,
{ workBudgetOptions: new StreamingWorkBudgetOptions(
MaxCompletionsPerFrame = 1, MaxUpdateMilliseconds: 100,
}; MaxCompletionAdmissions: 2,
MaxAdoptedCpuBytes: 1_000,
MaxEntityOperations: 100,
MaxGpuUploadBytes: 44,
MaxGlRetireOperations: 100,
DestinationReserveFraction: 0.75f));
controller.Tick(32, 32); controller.Tick(32, 32);
@ -250,9 +347,10 @@ public sealed class StreamingWorkBudgetTests
Assert.Equal(1, first.DeferredCompletions); Assert.Equal(1, first.DeferredCompletions);
Assert.Equal(44, first.DeferredAdoptedCpuBytes); Assert.Equal(44, first.DeferredAdoptedCpuBytes);
Assert.True(first.OldestDeferredAgeMilliseconds >= 0); Assert.True(first.OldestDeferredAgeMilliseconds >= 0);
Assert.Equal(1, first.LastFrame.Operations); Assert.Equal(3, first.LastFrame.Operations);
Assert.Equal(1, first.LastFrame.CompletedOperations); Assert.Equal(3, first.LastFrame.CompletedOperations);
Assert.Equal(44, first.LastFrame.Used.AdoptedCpuBytes); Assert.Equal(1, first.LastFrame.YieldCount);
Assert.Equal(88, first.LastFrame.Used.AdoptedCpuBytes);
controller.Tick(32, 32); controller.Tick(32, 32);
@ -263,6 +361,155 @@ public sealed class StreamingWorkBudgetTests
Assert.Equal(1, second.LastFrame.CompletedOperations); Assert.Equal(1, second.LastFrame.CompletedOperations);
} }
[Fact]
public void ControllerBoundsProductionAdmissionByCountBeforeAdoption()
{
uint center = StreamingRegion.EncodeLandblockIdForTest(32, 32);
var source = new QueueCompletionSource(
Loaded(center),
Loaded(StreamingRegion.EncodeLandblockIdForTest(32, 33)),
Loaded(StreamingRegion.EncodeLandblockIdForTest(32, 34)));
int applied = 0;
StreamingController controller = Controller(
source,
() => applied++,
WorkOptions(admissions: 2, cpuBytes: 1_000));
controller.Tick(32, 32);
Assert.Equal(2, applied);
Assert.Equal(1, source.BacklogCount);
Assert.Equal(1, controller.WorkDiagnostics.WorkerCompletionBacklog);
Assert.Equal(2, controller.WorkDiagnostics.LastFrame.Used.CompletionAdmissions);
Assert.Equal(0, controller.WorkDiagnostics.DeferredCompletions);
}
[Fact]
public void ControllerBoundsProductionAdmissionByRetainedCpuBytes()
{
uint center = StreamingRegion.EncodeLandblockIdForTest(32, 32);
var source = new QueueCompletionSource(
Loaded(center),
Loaded(StreamingRegion.EncodeLandblockIdForTest(32, 33)),
Loaded(StreamingRegion.EncodeLandblockIdForTest(32, 34)));
int applied = 0;
StreamingController controller = Controller(
source,
() => applied++,
WorkOptions(admissions: 64, cpuBytes: 44));
controller.Tick(32, 32);
Assert.Equal(1, applied);
Assert.Equal(2, source.BacklogCount);
Assert.Equal(44, controller.WorkDiagnostics.LastFrame.Used.AdoptedCpuBytes);
Assert.Equal(
StreamingWorkLimit.AdoptedCpuBytes,
controller.WorkDiagnostics.LastFrame.LastLimit);
}
[Fact]
public void DestinationAndUnloadPriorityNeverBypassExecutionBudgets()
{
uint center = StreamingRegion.EncodeLandblockIdForTest(32, 32);
uint ordinary = StreamingRegion.EncodeLandblockIdForTest(33, 33);
var source = new QueueCompletionSource(
Loaded(ordinary),
Loaded(center),
new LandblockStreamResult.Unloaded(0x1111FFFFu),
new LandblockStreamResult.Unloaded(0x2222FFFFu));
var applied = new List<uint>();
StreamingController controller = Controller(
source,
id => applied.Add(id),
WorkOptions(
admissions: 4,
cpuBytes: 1_000,
gpuBytes: 44,
retireOperations: 1));
controller.PriorityLandblockId = center;
controller.Tick(32, 32);
Assert.Equal([center], applied);
Assert.Equal(2, controller.WorkDiagnostics.DeferredCompletions);
Assert.Equal(1, controller.WorkDiagnostics.LastFrame.YieldCount);
Assert.Equal(44, controller.WorkDiagnostics.LastFrame.Used.GpuUploadBytes);
Assert.Equal(1, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations);
controller.Tick(32, 32);
Assert.Equal([center, ordinary], applied);
Assert.Equal(0, controller.WorkDiagnostics.DeferredCompletions);
Assert.Equal(1, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations);
}
[Fact]
public void StaleGenerationResultsConsumeAdmissionButRetainNoPayload()
{
uint center = StreamingRegion.EncodeLandblockIdForTest(32, 32);
var source = new QueueCompletionSource(
Loaded(center, generation: 1),
Loaded(StreamingRegion.EncodeLandblockIdForTest(32, 33), generation: 1),
Loaded(center));
int applied = 0;
StreamingController controller = Controller(
source,
() => applied++,
WorkOptions(admissions: 2, cpuBytes: 44));
controller.Tick(32, 32);
Assert.Equal(0, applied);
Assert.Equal(1, source.BacklogCount);
Assert.Equal(0, controller.WorkDiagnostics.DeferredCompletions);
Assert.Equal(0, controller.WorkDiagnostics.DeferredAdoptedCpuBytes);
Assert.Equal(2, controller.WorkDiagnostics.LastFrame.Used.CompletionAdmissions);
Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.AdoptedCpuBytes);
controller.Tick(32, 32);
Assert.Equal(1, applied);
}
private static StreamingController Controller(
ILandblockCompletionSource source,
Action applied,
StreamingWorkBudgetOptions options) =>
Controller(source, _ => applied(), options);
private static StreamingController Controller(
ILandblockCompletionSource source,
Action<uint> applied,
StreamingWorkBudgetOptions options)
{
var state = new GpuWorldState();
var presentation = new LandblockPresentationPipeline(
(build, _) => applied(build.Landblock.LandblockId),
state);
return new StreamingController(
enqueueLoad: static (_, _, _) => { },
enqueueUnload: static (_, _) => { },
completionSource: source,
state,
nearRadius: 1,
farRadius: 1,
presentationPipeline: presentation,
workBudgetOptions: options);
}
private static StreamingWorkBudgetOptions WorkOptions(
int admissions,
long cpuBytes,
long gpuBytes = 1_000,
int retireOperations = 100) => new(
MaxUpdateMilliseconds: 100,
MaxCompletionAdmissions: admissions,
MaxAdoptedCpuBytes: cpuBytes,
MaxEntityOperations: 100,
MaxGpuUploadBytes: gpuBytes,
MaxGlRetireOperations: retireOperations,
DestinationReserveFraction: 0.75f);
private static WorldEntity Entity(uint id, int meshRefs) private static WorldEntity Entity(uint id, int meshRefs)
{ {
var refs = new MeshRef[meshRefs]; var refs = new MeshRef[meshRefs];
@ -278,7 +525,9 @@ public sealed class StreamingWorkBudgetTests
}; };
} }
private static LandblockStreamResult.Loaded Loaded(uint landblockId) => new( private static LandblockStreamResult.Loaded Loaded(
uint landblockId,
ulong generation = 0) => new(
landblockId, landblockId,
LandblockStreamTier.Near, LandblockStreamTier.Near,
new LoadedLandblock( new LoadedLandblock(
@ -287,5 +536,39 @@ public sealed class StreamingWorkBudgetTests
Array.Empty<WorldEntity>()), Array.Empty<WorldEntity>()),
new LandblockMeshData( new LandblockMeshData(
new TerrainVertex[1], new TerrainVertex[1],
new uint[1])); new uint[1]),
generation);
private sealed class QueueCompletionSource(
params LandblockStreamResult[] results)
: ILandblockCompletionSource
{
private readonly Queue<LandblockStreamResult> _results = new(results);
public int BacklogCount => _results.Count;
public bool TryPeek(out LandblockStreamResult? result)
{
if (_results.TryPeek(out LandblockStreamResult? peeked))
{
result = peeked;
return true;
}
result = null;
return false;
}
public bool TryRead(out LandblockStreamResult? result)
{
if (_results.TryDequeue(out LandblockStreamResult? consumed))
{
result = consumed;
return true;
}
result = null;
return false;
}
}
} }

View file

@ -263,6 +263,34 @@ public class LandblockStreamerTests
Assert.Equal(43ul, unloaded.Generation); Assert.Equal(43ul, unloaded.Generation);
} }
[Fact]
public async Task CompletionSourcePeekPreservesExactResultAndBacklog()
{
using var streamer = new LandblockStreamer(loadLandblock: _ => null);
streamer.Start();
streamer.EnqueueUnload(0xABCE0000u, generation: 44);
for (int i = 0;
i < SpinMaxIterations && streamer.BacklogCount == 0;
i++)
{
await Task.Delay(SpinStepMs);
}
Assert.Equal(1, streamer.BacklogCount);
Assert.True(streamer.TryPeek(out LandblockStreamResult? firstPeek));
Assert.NotNull(firstPeek);
Assert.Equal(1, streamer.BacklogCount);
Assert.True(streamer.TryPeek(out LandblockStreamResult? secondPeek));
Assert.Same(firstPeek, secondPeek);
Assert.Equal(1, streamer.BacklogCount);
Assert.True(streamer.TryRead(out LandblockStreamResult? consumed));
Assert.Same(firstPeek, consumed);
Assert.Equal(0, streamer.BacklogCount);
Assert.False(streamer.TryRead(out _));
}
[Fact] [Fact]
public async Task Load_ExecutesLoaderOnWorkerThread() public async Task Load_ExecutesLoaderOnWorkerThread()
{ {

View file

@ -18,7 +18,7 @@ public class StreamingControllerPriorityApplyTests
generation); generation);
[Fact] [Fact]
public void PriorityLandblock_isApplied_evenWhenBeyondPerFrameCap() public void PriorityLandblock_WinsExecutionOrderAfterBoundedAdmission()
{ {
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180); uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[] var outbox = new Queue<LandblockStreamResult>(new LandblockStreamResult[]
@ -42,14 +42,18 @@ public class StreamingControllerPriorityApplyTests
return batch; return batch;
}, },
applyTerrain: (lb, _) => applied.Add(lb.LandblockId), applyTerrain: (lb, _) => applied.Add(lb.LandblockId),
state: state, nearRadius: 4, farRadius: 12) state: state,
nearRadius: 4,
farRadius: 12,
workBudgetOptions: GenerousBudget())
{ MaxCompletionsPerFrame = 4 }; { MaxCompletionsPerFrame = 4 };
ctrl.PriorityLandblockId = priority; ctrl.PriorityLandblockId = priority;
ctrl.Tick(169, 180); ctrl.Tick(169, 180);
Assert.Contains(priority, applied); // priority applied THIS tick Assert.NotEmpty(applied);
Assert.True(applied.Count <= 5); // did not blindly flush the whole outbox Assert.Equal(priority, applied[0]);
Assert.True(applied.Count <= 5);
} }
[Fact] [Fact]
@ -98,10 +102,8 @@ public class StreamingControllerPriorityApplyTests
public void PriorityNeverArrives_noThrow_noLoss_noDoubleApply() public void PriorityNeverArrives_noThrow_noLoss_noDoubleApply()
{ {
// While a PriorityLandblockId is set but the matching completion never // While a PriorityLandblockId is set but the matching completion never
// arrives (e.g. the load failed), the hunt moves outbox completions into // arrives (e.g. the load failed), ordinary results still enter the typed
// _deferredApply and they drain at the per-frame budget — same backpressure // Near/Far queues and advance under the same budget without loss.
// as the normal throttle, just relocated — until the caller clears
// PriorityLandblockId (the TAS MaxContinue safety net does this on timeout).
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180); uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
uint otherId0 = StreamingRegion.EncodeLandblockIdForTest(169, 181); uint otherId0 = StreamingRegion.EncodeLandblockIdForTest(169, 181);
uint otherId1 = StreamingRegion.EncodeLandblockIdForTest(169, 182); uint otherId1 = StreamingRegion.EncodeLandblockIdForTest(169, 182);
@ -149,6 +151,15 @@ public class StreamingControllerPriorityApplyTests
Assert.Equal(3, applied.Count); Assert.Equal(3, applied.Count);
} }
private static StreamingWorkBudgetOptions GenerousBudget() => new(
MaxUpdateMilliseconds: 100,
MaxCompletionAdmissions: 64,
MaxAdoptedCpuBytes: 16 * StreamingWorkBudgetOptions.MiB,
MaxEntityOperations: 10_000,
MaxGpuUploadBytes: 16 * StreamingWorkBudgetOptions.MiB,
MaxGlRetireOperations: 10_000,
DestinationReserveFraction: 0.75f);
[Fact] [Fact]
public void DeferredCompaction_ApplyFailureRetainsExactResultWithoutReplayingCommittedPrefix() public void DeferredCompaction_ApplyFailureRetainsExactResultWithoutReplayingCommittedPrefix()
{ {
@ -223,7 +234,15 @@ public class StreamingControllerPriorityApplyTests
applyTerrain: (build, _) => applied.Add(build.LandblockId), applyTerrain: (build, _) => applied.Add(build.LandblockId),
state: state, state: state,
nearRadius: 4, nearRadius: 4,
farRadius: 12) farRadius: 12,
workBudgetOptions: new StreamingWorkBudgetOptions(
MaxUpdateMilliseconds: 100,
MaxCompletionAdmissions: 4,
MaxAdoptedCpuBytes: 1_000_000,
MaxEntityOperations: 1_000,
MaxGpuUploadBytes: 1_000_000,
MaxGlRetireOperations: 1_000,
DestinationReserveFraction: 0.75f))
{ MaxCompletionsPerFrame = 4, PriorityLandblockId = priority }; { MaxCompletionsPerFrame = 4, PriorityLandblockId = priority };
ctrl.Tick(169, 180); ctrl.Tick(169, 180);