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
admission ledger; `LandblockStreamResultCost` assigns deterministic charges to
immutable completion arrays and logical retained entries without claiming to
measure CLR allocator overhead. During Slice E1 the ledger is observational:
the existing scheduler still executes its accepted work, while lifecycle
artifacts record would-yield limits, elapsed work, retained backlog bytes/age,
and pending publication/retirement counts. Enforcement belongs to the explicit
Slice E scheduler, not to presentation owners or the worker.
measure CLR allocator overhead. `LandblockStreamer` exposes one allocation-free
single-consumer peek/read source so the update thread prices a result before
adopting it. `StreamingController` is the sole scheduler: it admits through the
typed meter into reusable destination/control/unload/Near/Far FIFOs, preserves
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

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
> non-growing and no checkpoint retained staged or retiring bytes. [Residency
> 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
> approval gates.
> cost-budgeted streaming/retirement is active. E2 has replaced the flat
> 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
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
production streaming, and typed unified residency. Its physical capped,
uncapped, and dense connected gates pass without a visual-quality change.
Slice E cost-budgets portal publication and retirement next. Slices F/G remain
behind their explicit ECS approval gate, and Slice J remains behind its
gameplay-owner approval gate.
Slice E0E2 now define and enforce typed completion admission through explicit
stable priority queues; E3 adds immediate old-generation quiesce and cursored
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
status hand selected-object availability and existing

View file

@ -1,6 +1,6 @@
# 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`
**Baseline:** Slice D closeout commit `66690805`
**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
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
- Add destination-generation world quiesce.

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

View file

@ -1004,7 +1004,15 @@ public sealed class LandblockPresentationPipelineTests
state,
nearRadius: 0,
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;
Assert.Throws<InvalidOperationException>(() => controller.Tick(0x70, 0x70));
@ -1021,8 +1029,8 @@ public sealed class LandblockPresentationPipelineTests
Assert.Equal(
[
$"publish:{priorityId:X8}",
$"publish:{laterId:X8}",
$"unload:{unloadId:X8}",
$"publish:{laterId:X8}",
],
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);
}
[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]
public void MeterUsesInjectedMonotonicClockAndRecordsOverrunAndFailure()
{
@ -129,6 +163,64 @@ public sealed class StreamingWorkBudgetTests
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]
public void CompletionCostChargesExactArraysAndDeterministicLogicalEntries()
{
@ -239,10 +331,15 @@ public sealed class StreamingWorkBudgetTests
applyTerrain: static (_, _) => { },
state: new GpuWorldState(),
nearRadius: 1,
farRadius: 1)
{
MaxCompletionsPerFrame = 1,
};
farRadius: 1,
workBudgetOptions: new StreamingWorkBudgetOptions(
MaxUpdateMilliseconds: 100,
MaxCompletionAdmissions: 2,
MaxAdoptedCpuBytes: 1_000,
MaxEntityOperations: 100,
MaxGpuUploadBytes: 44,
MaxGlRetireOperations: 100,
DestinationReserveFraction: 0.75f));
controller.Tick(32, 32);
@ -250,9 +347,10 @@ public sealed class StreamingWorkBudgetTests
Assert.Equal(1, first.DeferredCompletions);
Assert.Equal(44, first.DeferredAdoptedCpuBytes);
Assert.True(first.OldestDeferredAgeMilliseconds >= 0);
Assert.Equal(1, first.LastFrame.Operations);
Assert.Equal(1, first.LastFrame.CompletedOperations);
Assert.Equal(44, first.LastFrame.Used.AdoptedCpuBytes);
Assert.Equal(3, first.LastFrame.Operations);
Assert.Equal(3, first.LastFrame.CompletedOperations);
Assert.Equal(1, first.LastFrame.YieldCount);
Assert.Equal(88, first.LastFrame.Used.AdoptedCpuBytes);
controller.Tick(32, 32);
@ -263,6 +361,155 @@ public sealed class StreamingWorkBudgetTests
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)
{
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,
LandblockStreamTier.Near,
new LoadedLandblock(
@ -287,5 +536,39 @@ public sealed class StreamingWorkBudgetTests
Array.Empty<WorldEntity>()),
new LandblockMeshData(
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);
}
[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]
public async Task Load_ExecutesLoaderOnWorkerThread()
{

View file

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