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:
parent
ac45cb1bd7
commit
b8f6317fe1
15 changed files with 1261 additions and 336 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -93,9 +93,10 @@ The separately authorized modern-runtime performance program has completed
|
||||||
Slices A–D: corrected measurement, prepared-package bake/dedup, package-only
|
Slices A–D: 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 E0–E2 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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
270
src/AcDream.App/Streaming/StreamingCompletionQueue.cs
Normal file
270
src/AcDream.App/Streaming/StreamingCompletionQueue.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue