diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index d66a46d0..fa41e8cc 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -708,6 +708,16 @@ replace state produced by another job. CPU mesh extraction may still be schedule onto `ObjectMeshManager`'s thread-safe work queue, but the worker never publishes partially hydrated cell membership or shell placement to the renderer. +`RuntimeOptions.StreamingWorkBudgets` is the single startup profile for +update-thread streaming cost. `StreamingWorkMeter` owns the frame-scoped +admission ledger; `LandblockStreamResultCost` assigns deterministic charges to +immutable completion arrays and logical retained entries without claiming to +measure CLR allocator overhead. During Slice E1 the ledger is observational: +the existing scheduler still executes its accepted work, while lifecycle +artifacts record would-yield limits, elapsed work, retained backlog bytes/age, +and pending publication/retirement counts. Enforcement belongs to the explicit +Slice E scheduler, not to presentation owners or the worker. + ### World-reveal readiness ownership `WorldRevealCoordinator` is the single App-layer owner of each login or portal diff --git a/docs/plans/2026-07-24-modern-runtime-architecture.md b/docs/plans/2026-07-24-modern-runtime-architecture.md index e9dfccdd..dcd9f11a 100644 --- a/docs/plans/2026-07-24-modern-runtime-architecture.md +++ b/docs/plans/2026-07-24-modern-runtime-architecture.md @@ -2,7 +2,7 @@ **Date:** 2026-07-24 -**Status:** Slices A–D complete. Slice E is active. +**Status:** Slices A–D complete. Slice E is active (E0/E1 complete). **Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit. @@ -726,6 +726,12 @@ checkpoint retained staged or retiring bytes. Evidence: **Active plan:** [`2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md`](2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md). +**Checkpoint 2026-07-24:** E0 fixed the retail/adaptation contract and current +owner inventory. E1 landed the validated work profile, deterministic meter and +completion charge model, plus lifecycle-artifact shadow diagnostics while +preserving the prior scheduler's execution. E2 is the first enforcement +cutover. + **Purpose:** Remove update-thread portal transactions. - Introduce explicit stage queues and `StreamingWorkBudget`. diff --git a/docs/plans/2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md b/docs/plans/2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md index 526aa414..37826461 100644 --- a/docs/plans/2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md +++ b/docs/plans/2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md @@ -1,6 +1,6 @@ # Modern Runtime Slice E — Cost-budgeted streaming and retirement -**Status:** active — E0 contract/inventory complete +**Status:** active — E0/E1 complete; E2 explicit admission queues next **Program:** `docs/plans/2026-07-24-modern-runtime-architecture.md` **Baseline:** Slice D closeout commit `66690805` **Behavior contract:** no visual-quality or retail-behavior reduction @@ -232,6 +232,20 @@ the active destination generation. Far-ring work may continue after reveal. lifecycle artifacts. - Preserve current execution while measuring the would-yield decisions. +**Complete 2026-07-24.** `RuntimeOptions` now owns one validated scheduling +profile (time, admissions, retained CPU bytes, entity operations, requested GPU +bytes, GL retire operations, and destination reserve). The single-thread meter +has a deterministic clock seam, first-operation progress rule, explicit +yield/oversize/failure/overrun facts, and no effect on legacy execution at this +checkpoint. Immutable completion payloads receive deterministic charges for +their exact array data and logical retained entries; this is deliberately not +misreported as CLR heap size. Lifecycle JSON now includes last-frame work, +deferred retained bytes/age, and pending publication/retirement counts. + +Validation: complete App suite 3,643 passed / 3 skipped; complete solution +8,124 passed / 5 skipped; Release solution build green. Existing priority, +retry, stale-generation, and deferred-compaction tests remain green. + ### E2 — Explicit admission queues - Replace `_deferredApply` with stable generation/priority queues. diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 8c6f99c5..d646c55b 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -295,7 +295,8 @@ internal sealed class SessionPlayerCompositionPhase nearRadius: nearRadius, farRadius: farRadius, presentationPipeline: live.LandblockPipeline, - clearPendingLoads: streamerLease.Resource.ClearPendingLoads); + clearPendingLoads: streamerLease.Resource.ClearPendingLoads, + workBudgetOptions: d.Options.StreamingWorkBudgets); var streamingOriginRecenter = new StreamingOriginRecenterCoordinator( streaming, d.WorldOrigin); diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs index 51dea8db..3a19133e 100644 --- a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs +++ b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs @@ -77,6 +77,7 @@ internal sealed record WorldLifecycleResourceSnapshot( long DatObjectCacheHits, long DatObjectCacheMisses, long DatObjectCacheEvictions, + StreamingWorkDiagnostics StreamingWork, ResidencySnapshot Residency, double Fps, double FrameMilliseconds, diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs index 0f753bf7..124242b4 100644 --- a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs +++ b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs @@ -163,6 +163,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource DatObjectCacheHits: datObjectCacheStats.Hits, DatObjectCacheMisses: datObjectCacheStats.Misses, DatObjectCacheEvictions: datObjectCacheStats.Evictions, + StreamingWork: _streaming.WorkDiagnostics, Residency: residency, Fps: render.Fps, FrameMilliseconds: render.FrameMilliseconds, diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 5166d10c..1ba025a1 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -2,6 +2,7 @@ using System; using System.Globalization; using System.IO; using AcDream.App.Rendering.Residency; +using AcDream.App.Streaming; namespace AcDream.App; @@ -52,7 +53,8 @@ public sealed record RuntimeOptions( string? AutomationArtifactDirectory, float FogStartMultiplier, float FogEndMultiplier, - ResidencyBudgetOptions ResidencyBudgets) + ResidencyBudgetOptions ResidencyBudgets, + StreamingWorkBudgetOptions StreamingWorkBudgets) { /// /// Build options from the process environment. Used by @@ -110,7 +112,8 @@ public sealed record RuntimeOptions( NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")), FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f, FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f, - ResidencyBudgets: ResidencyBudgetOptions.Parse(env)); + ResidencyBudgets: ResidencyBudgetOptions.Parse(env), + StreamingWorkBudgets: StreamingWorkBudgetOptions.Parse(env)); } /// True iff live-mode credentials are present and valid for connecting. diff --git a/src/AcDream.App/Streaming/LandblockStreamResultCost.cs b/src/AcDream.App/Streaming/LandblockStreamResultCost.cs new file mode 100644 index 00000000..b0f01426 --- /dev/null +++ b/src/AcDream.App/Streaming/LandblockStreamResultCost.cs @@ -0,0 +1,187 @@ +using System.Runtime.CompilerServices; +using AcDream.App.Rendering.Wb; +using AcDream.Core.Terrain; +using AcDream.Core.World; + +namespace AcDream.App.Streaming; + +/// +/// Deterministic scheduling charge derived from one immutable worker result. +/// It is not a claim about CLR object-header or allocator-bucket size: those +/// implementation details are neither stable nor observable without walking +/// the managed heap. Exact array payloads and logical retained entries are +/// charged consistently so admission has a deterministic unit. +/// +public readonly record struct LandblockStreamCostEstimate( + StreamingWorkCost Work, + long TerrainPayloadBytes, + int Entities, + int MeshReferences, + int VisibilityCells, + int EnvCellShells, + int PortalConnections, + int PortalPolygonVertices, + int PhysicsEnvCells, + int PhysicsEnvironments, + int PhysicsSetups, + int PhysicsGfxObjects); + +public static class LandblockStreamResultCost +{ + private const int ReferenceChargeBytes = 8; + private const int DictionaryEntryChargeBytes = 16; + + public static LandblockStreamCostEstimate Estimate( + LandblockStreamResult result) + { + ArgumentNullException.ThrowIfNull(result); + return result switch + { + LandblockStreamResult.Loaded loaded => + Estimate(loaded.Build, loaded.MeshData), + LandblockStreamResult.Promoted promoted => + Estimate(promoted.Build, promoted.MeshData), + _ => new LandblockStreamCostEstimate( + new StreamingWorkCost(CompletionAdmissions: 1), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0), + }; + } + + public static LandblockStreamCostEstimate Estimate( + LandblockBuild build, + LandblockMeshData meshData) + { + ArgumentNullException.ThrowIfNull(build); + ArgumentNullException.ThrowIfNull(meshData); + + long terrainBytes = Add( + Multiply(meshData.Vertices.LongLength, Unsafe.SizeOf()), + Multiply(meshData.Indices.LongLength, sizeof(uint))); + long retainedBytes = terrainBytes; + + IReadOnlyList entities = build.Landblock.Entities; + int meshReferences = 0; + retainedBytes = Add( + retainedBytes, + Multiply(entities.Count, ReferenceChargeBytes)); + for (int i = 0; i < entities.Count; i++) + { + int count = entities[i].MeshRefs.Count; + meshReferences = SaturatingAdd(meshReferences, count); + retainedBytes = Add( + retainedBytes, + Multiply(count, Unsafe.SizeOf())); + } + + int visibilityCells = 0; + int shells = 0; + int portalConnections = 0; + int portalPolygonVertices = 0; + if (build.EnvCells is { } envCells) + { + visibilityCells = envCells.VisibilityCells.Length; + shells = envCells.Shells.Length; + retainedBytes = Add( + retainedBytes, + Multiply( + (long)visibilityCells + shells, + ReferenceChargeBytes)); + + foreach (var cell in envCells.VisibilityCells) + { + portalConnections = SaturatingAdd( + portalConnections, + cell.Portals.Count); + retainedBytes = Add( + retainedBytes, + Multiply( + cell.Portals.Count, + Unsafe.SizeOf())); + retainedBytes = Add( + retainedBytes, + Multiply( + cell.ClipPlanes.Count, + Unsafe.SizeOf())); + retainedBytes = Add( + retainedBytes, + Multiply( + cell.VisibleCells.Count, + sizeof(uint))); + + foreach (System.Numerics.Vector3[] polygon in cell.PortalPolygons) + { + portalPolygonVertices = SaturatingAdd( + portalPolygonVertices, + polygon.Length); + retainedBytes = Add( + retainedBytes, + Multiply( + polygon.LongLength, + Unsafe.SizeOf())); + } + } + + foreach (EnvCellShellPlacement shell in envCells.Shells) + { + retainedBytes = Add( + retainedBytes, + Multiply(shell.Surfaces.Length, sizeof(ushort))); + } + } + + PhysicsDatBundle physics = + build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty; + int physicsEnvCells = physics.EnvCells.Count; + int physicsEnvironments = physics.Environments.Count; + int physicsSetups = physics.Setups.Count; + int physicsGfxObjects = physics.GfxObjs.Count; + long physicsEntries = (long)physicsEnvCells + + physicsEnvironments + + physicsSetups + + physicsGfxObjects; + retainedBytes = Add( + retainedBytes, + Multiply(physicsEntries, DictionaryEntryChargeBytes)); + + return new LandblockStreamCostEstimate( + new StreamingWorkCost( + CompletionAdmissions: 1, + AdoptedCpuBytes: retainedBytes, + EntityOperations: entities.Count, + GpuUploadBytes: terrainBytes), + terrainBytes, + entities.Count, + meshReferences, + visibilityCells, + shells, + portalConnections, + portalPolygonVertices, + physicsEnvCells, + physicsEnvironments, + physicsSetups, + physicsGfxObjects); + } + + private static long Multiply(long count, int elementBytes) => + count <= 0 + ? 0 + : count > long.MaxValue / elementBytes + ? long.MaxValue + : count * elementBytes; + + private static long Add(long left, long right) => + left > long.MaxValue - right ? long.MaxValue : left + right; + + private static int SaturatingAdd(int left, int right) => + left > int.MaxValue - right ? int.MaxValue : left + right; +} diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index c746f644..38ae21ae 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using AcDream.App.Rendering.Wb; using AcDream.Core.Terrain; using AcDream.Core.World; @@ -18,6 +19,11 @@ namespace AcDream.App.Streaming; /// public sealed class StreamingController : IStreamingFrameBackend { + private readonly record struct DeferredCompletion( + LandblockStreamResult Result, + StreamingWorkCost Cost, + long EnqueuedTimestamp); + private sealed class OriginRecenterRetirement { public bool RadiiConverged; @@ -38,6 +44,10 @@ public sealed class StreamingController : IStreamingFrameBackend private readonly Func> _drainCompletions; private readonly Action? _clearPendingLoads; private readonly LandblockPresentationPipeline _presentation; + private readonly StreamingWorkBudget _workBudget; + private StreamingWorkMeter? _activeWorkMeter; + private StreamingWorkMeterSnapshot _lastWorkMeter; + private long _deferredAdoptedCpuBytes; private readonly GpuWorldState _state; private StreamingRegion? _region; @@ -133,6 +143,13 @@ public sealed class StreamingController : IStreamingFrameBackend // GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface. public int DeferredApplyBacklog => _deferredApply.Count; public int PendingRetirementCount => _presentation.PendingRetirementCount; + public StreamingWorkDiagnostics WorkDiagnostics => new( + _lastWorkMeter, + _deferredApply.Count, + _deferredAdoptedCpuBytes, + OldestDeferredAgeMilliseconds(), + _presentation.PendingPublicationCount, + _presentation.PendingRetirementCount); internal bool IsCollapsedToDungeon => _collapsed; /// @@ -180,7 +197,7 @@ public sealed class StreamingController : IStreamingFrameBackend // Completions that were drained past a priority item get buffered here // so they still apply over subsequent frames without loss. - private readonly List _deferredApply = new(); + private readonly List _deferredApply = new(); /// /// Internal compatibility seam for hermetic controller policy tests. Live /// composition cannot supply presentation callbacks; it must use the @@ -232,7 +249,8 @@ public sealed class StreamingController : IStreamingFrameBackend int nearRadius, int farRadius, LandblockPresentationPipeline presentationPipeline, - Action? clearPendingLoads = null) + Action? clearPendingLoads = null, + StreamingWorkBudgetOptions? workBudgetOptions = null) { _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; @@ -241,6 +259,8 @@ public sealed class StreamingController : IStreamingFrameBackend _state = state; _presentation = presentationPipeline ?? throw new ArgumentNullException(nameof(presentationPipeline)); + _workBudget = (workBudgetOptions ?? StreamingWorkBudgetOptions.Default) + .ToBudget(); if (!_presentation.MatchesState(_state)) { throw new ArgumentException( @@ -425,22 +445,32 @@ public sealed class StreamingController : IStreamingFrameBackend try { for (int i = 0; i < pending.Count; i++) - _presentation.ResumePublication(pending[i]); + { + LandblockStreamResult result = pending[i]; + ObserveResultOperation( + result, + "publication-retry", + () => _presentation.ResumePublication(result)); + } } finally { // A completed staged result can still be present in the deferred // FIFO that originally retained it. Remove only those exact // objects; unrelated accepted completions keep their order. - _deferredApply.RemoveAll(result => + _deferredApply.RemoveAll(entry => { for (int i = 0; i < pending.Count; i++) { - if (ReferenceEquals(result, pending[i])) - return !_presentation.HasPendingPublication(result); + if (ReferenceEquals(entry.Result, pending[i])) + { + return !_presentation.HasPendingPublication( + entry.Result); + } } return false; }); + RecalculateDeferredAdoptedBytes(); } } @@ -460,59 +490,74 @@ public sealed class StreamingController : IStreamingFrameBackend /// public void Tick(int observerCx, int observerCy, bool insideDungeon = false) { - if (_originRecenterRetirement is not null) + if (_activeWorkMeter is not null) + throw new InvalidOperationException( + "StreamingController.Tick cannot be reentered."); + + var meter = new StreamingWorkMeter(_workBudget); + _activeWorkMeter = meter; + try { + if (_originRecenterRetirement is not null) + { + _presentation.AdvanceRetirements(); + return; + } + + if (_pendingRadiiReconfiguration is not null) + AdvanceRadiiReconfiguration(); + else if (_deferredRadiiRequest is { } deferred) + { + _deferredRadiiRequest = null; + ReconfigureRadii(deferred.NearRadius, deferred.FarRadius); + } + + // Presentation-owner failures never roll back spatial state. Resume + // unfinished owner ledgers before accepting replacement publications. _presentation.AdvanceRetirements(); - return; - } + // Complete an admitted publication before this frame can mutate its + // desired tier or spatial residence. A later recenter may then demote + // or retire the fully known owner set through the normal ledger. + ConvergePendingPublications(); - if (_pendingRadiiReconfiguration is not null) - AdvanceRadiiReconfiguration(); - else if (_deferredRadiiRequest is { } deferred) - { - _deferredRadiiRequest = null; - ReconfigureRadii(deferred.NearRadius, deferred.FarRadius); - } + uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy); - // Presentation-owner failures never roll back spatial state. Resume - // unfinished owner ledgers before accepting replacement publications. - _presentation.AdvanceRetirements(); - // Complete an admitted publication before this frame can mutate its - // desired tier or spatial residence. A later recenter may then demote - // or retire the fully known owner set through the normal ledger. - ConvergePendingPublications(); - - uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy); - - if (_collapsed) - { - // Hysteresis. Cases: - // - Still in the SAME dungeon landblock → hold (sweep stragglers). - // - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon) - // → re-collapse onto it. - // - CurrCell flickered null but the player hasn't gone anywhere: the - // observer landblock reverts to the position-derived value, which for a - // dungeon is only ever the ADJACENT off-by-one landblock (negative cell- - // local Y). Hold — never expand on an adjacent flicker. - // - Genuinely left to a DISTANT landblock (portal/teleport out, always far - // from the ocean-grid dungeon block) → expand. - if (insideDungeon && centerId != _collapsedCenter) + if (_collapsed) + { + // Hysteresis. Cases: + // - Still in the SAME dungeon landblock → hold (sweep stragglers). + // - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon) + // → re-collapse onto it. + // - CurrCell flickered null but the player hasn't gone anywhere: the + // observer landblock reverts to the position-derived value, which for a + // dungeon is only ever the ADJACENT off-by-one landblock (negative cell- + // local Y). Hold — never expand on an adjacent flicker. + // - Genuinely left to a DISTANT landblock (portal/teleport out, always far + // from the ocean-grid dungeon block) → expand. + if (insideDungeon && centerId != _collapsedCenter) + EnterDungeonCollapse(observerCx, observerCy, centerId); + else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1) + ExitDungeonExpand(observerCx, observerCy); + else + SweepCollapsed(); + } + else if (insideDungeon) + { EnterDungeonCollapse(observerCx, observerCy, centerId); - else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1) - ExitDungeonExpand(observerCx, observerCy); + } else - SweepCollapsed(); - } - else if (insideDungeon) - { - EnterDungeonCollapse(observerCx, observerCy, centerId); - } - else - { - NormalTick(observerCx, observerCy); - } + { + NormalTick(observerCx, observerCy); + } - DrainAndApply(); + DrainAndApply(); + } + finally + { + meter.FinishFrame(); + _lastWorkMeter = meter.Snapshot; + _activeWorkMeter = null; + } } private void AdvanceRadiiReconfiguration() @@ -580,7 +625,7 @@ public sealed class StreamingController : IStreamingFrameBackend _region = pending.Region; NearRadius = pending.NearRadius; FarRadius = pending.FarRadius; - _deferredApply.Clear(); + ClearDeferred(); _pendingRadiiReconfiguration = null; } } @@ -721,7 +766,7 @@ public sealed class StreamingController : IStreamingFrameBackend _collapsed = true; _collapsedCenter = centerId; _clearPendingLoads?.Invoke(); - _deferredApply.Clear(); + ClearDeferred(); _region = null; foreach (var id in _state.LoadedLandblockIds) @@ -1002,7 +1047,7 @@ public sealed class StreamingController : IStreamingFrameBackend } if (!transaction.DeferredApplyCleared) { - _deferredApply.Clear(); + ClearDeferred(); transaction.DeferredApplyCleared = true; } if (!transaction.RegionCleared) @@ -1061,7 +1106,7 @@ public sealed class StreamingController : IStreamingFrameBackend AdvanceGeneration(); _collapsed = false; _clearPendingLoads?.Invoke(); - _deferredApply.Clear(); + ClearDeferred(); // Commit the old region boundary before any presentation owner is // advanced. New same-id publications are fenced by the presentation // pipeline's retryable retirement ledger. @@ -1111,13 +1156,15 @@ public sealed class StreamingController : IStreamingFrameBackend && !_presentation.HasPendingPublication(result)) continue; if (IsPublicationBlockedByRetirement(result)) - _deferredApply.Add(result); + Defer(result); else if (result is LandblockStreamResult.Unloaded || IsWithinPriorityRing(ResultLandblockId(result))) { try { - ApplyResult(result); // free (unload) or behind-the-fade near ring + ApplyResultObserved( + result, + "priority-or-unload"); } catch { @@ -1129,14 +1176,14 @@ public sealed class StreamingController : IStreamingFrameBackend // its failed result is consumed, but the untouched // suffix must still survive. if (_presentation.HasPendingPublication(result)) - _deferredApply.Add(result); + Defer(result); for (int suffix = chunkIndex + 1; suffix < chunk.Count; suffix++) - _deferredApply.Add(chunk[suffix]); + Defer(chunk[suffix]); throw; } } else - _deferredApply.Add(result); // a GPU-upload load — meter it in step 2 + Defer(result); // a GPU-upload load — meter it in step 2 } } @@ -1151,11 +1198,12 @@ public sealed class StreamingController : IStreamingFrameBackend { while (applied < budget && read < _deferredApply.Count) { - LandblockStreamResult result = _deferredApply[read]; + DeferredCompletion entry = _deferredApply[read]; + LandblockStreamResult result = entry.Result; if (IsPublicationBlockedByRetirement(result)) { if (write != read) - _deferredApply[write] = result; + _deferredApply[write] = entry; write++; read++; continue; @@ -1166,7 +1214,10 @@ public sealed class StreamingController : IStreamingFrameBackend // exact result and the untouched tail for retry. try { - ApplyResult(result); + ApplyResultObserved( + result, + "deferred-publication", + entry.Cost); read++; applied++; } @@ -1190,10 +1241,109 @@ public sealed class StreamingController : IStreamingFrameBackend // retained blocked prefix and the untouched FIFO tail keep their // original relative order. if (read > write) - _deferredApply.RemoveRange(write, read - write); + RemoveDeferredRange(write, read - write); } } + private void ApplyResultObserved( + LandblockStreamResult result, + string stage, + StreamingWorkCost? knownCost = null) => + ObserveResultOperation( + result, + stage, + () => ApplyResult(result), + knownCost); + + private void ObserveResultOperation( + LandblockStreamResult result, + string stage, + Action operation, + StreamingWorkCost? knownCost = null) + { + StreamingWorkMeter? meter = _activeWorkMeter; + if (meter is null) + { + operation(); + return; + } + + StreamingWorkCost cost = knownCost + ?? LandblockStreamResultCost.Estimate(result).Work; + StreamingWorkAdmission admission = meter.TryReserve(cost, stage); + if (admission == StreamingWorkAdmission.Yielded) + meter.ForceReserveObserved(cost, stage); + + try + { + operation(); + meter.Complete(); + } + catch + { + meter.Fail(); + throw; + } + } + + 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; + /// /// Apply a single with the full side- /// effects: terrain upload, GPU state, and the re-hydration callback. diff --git a/src/AcDream.App/Streaming/StreamingWorkBudget.cs b/src/AcDream.App/Streaming/StreamingWorkBudget.cs new file mode 100644 index 00000000..85cbba56 --- /dev/null +++ b/src/AcDream.App/Streaming/StreamingWorkBudget.cs @@ -0,0 +1,364 @@ +using System.Diagnostics; + +namespace AcDream.App.Streaming; + +/// +/// Immutable, validated per-frame streaming work profile. +/// +public readonly record struct StreamingWorkBudget +{ + public StreamingWorkBudget( + TimeSpan maxUpdateTime, + int maxCompletionAdmissions, + long maxAdoptedCpuBytes, + int maxEntityOperations, + long maxGpuUploadBytes, + int maxGlRetireOperations, + float destinationReserveFraction) + { + if (maxUpdateTime <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(maxUpdateTime)); + if (maxCompletionAdmissions <= 0) + throw new ArgumentOutOfRangeException(nameof(maxCompletionAdmissions)); + if (maxAdoptedCpuBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(maxAdoptedCpuBytes)); + if (maxEntityOperations <= 0) + throw new ArgumentOutOfRangeException(nameof(maxEntityOperations)); + if (maxGpuUploadBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(maxGpuUploadBytes)); + if (maxGlRetireOperations <= 0) + throw new ArgumentOutOfRangeException(nameof(maxGlRetireOperations)); + if (!float.IsFinite(destinationReserveFraction) + || destinationReserveFraction <= 0f + || destinationReserveFraction >= 1f) + { + throw new ArgumentOutOfRangeException( + nameof(destinationReserveFraction)); + } + + MaxUpdateTime = maxUpdateTime; + MaxCompletionAdmissions = maxCompletionAdmissions; + MaxAdoptedCpuBytes = maxAdoptedCpuBytes; + MaxEntityOperations = maxEntityOperations; + MaxGpuUploadBytes = maxGpuUploadBytes; + MaxGlRetireOperations = maxGlRetireOperations; + DestinationReserveFraction = destinationReserveFraction; + } + + public TimeSpan MaxUpdateTime { get; } + public int MaxCompletionAdmissions { get; } + public long MaxAdoptedCpuBytes { get; } + public int MaxEntityOperations { get; } + public long MaxGpuUploadBytes { get; } + public int MaxGlRetireOperations { get; } + public float DestinationReserveFraction { get; } + + internal void Validate() + { + if (MaxUpdateTime <= TimeSpan.Zero + || MaxCompletionAdmissions <= 0 + || MaxAdoptedCpuBytes <= 0 + || MaxEntityOperations <= 0 + || MaxGpuUploadBytes <= 0 + || MaxGlRetireOperations <= 0 + || !float.IsFinite(DestinationReserveFraction) + || DestinationReserveFraction <= 0f + || DestinationReserveFraction >= 1f) + { + throw new ArgumentException( + "Streaming work budget is not a valid complete profile.", + nameof(StreamingWorkBudget)); + } + } +} + +/// +/// Conservative, known cost of one atomic streaming operation. +/// +public readonly record struct StreamingWorkCost( + int CompletionAdmissions = 0, + long AdoptedCpuBytes = 0, + int EntityOperations = 0, + long GpuUploadBytes = 0, + int GlRetireOperations = 0) +{ + public bool IsZero => + CompletionAdmissions == 0 + && AdoptedCpuBytes == 0 + && EntityOperations == 0 + && GpuUploadBytes == 0 + && GlRetireOperations == 0; + + internal void Validate() + { + if (CompletionAdmissions < 0) + throw new ArgumentOutOfRangeException(nameof(CompletionAdmissions)); + if (AdoptedCpuBytes < 0) + throw new ArgumentOutOfRangeException(nameof(AdoptedCpuBytes)); + if (EntityOperations < 0) + throw new ArgumentOutOfRangeException(nameof(EntityOperations)); + if (GpuUploadBytes < 0) + throw new ArgumentOutOfRangeException(nameof(GpuUploadBytes)); + if (GlRetireOperations < 0) + throw new ArgumentOutOfRangeException(nameof(GlRetireOperations)); + } + + internal StreamingWorkCost Add(StreamingWorkCost other) => new( + SaturatingAdd(CompletionAdmissions, other.CompletionAdmissions), + SaturatingAdd(AdoptedCpuBytes, other.AdoptedCpuBytes), + SaturatingAdd(EntityOperations, other.EntityOperations), + SaturatingAdd(GpuUploadBytes, other.GpuUploadBytes), + SaturatingAdd(GlRetireOperations, other.GlRetireOperations)); + + private static int SaturatingAdd(int left, int right) => + left > int.MaxValue - right ? int.MaxValue : left + right; + + private static long SaturatingAdd(long left, long right) => + left > long.MaxValue - right ? long.MaxValue : left + right; +} + +public enum StreamingWorkAdmission : byte +{ + Admitted, + OversizedProgress, + Yielded, +} + +public enum StreamingWorkLimit : byte +{ + None, + Time, + CompletionAdmissions, + AdoptedCpuBytes, + EntityOperations, + GpuUploadBytes, + GlRetireOperations, +} + +/// +/// Immutable observation of one frame's streaming work. +/// +public readonly record struct StreamingWorkMeterSnapshot( + StreamingWorkCost Used, + int Operations, + int CompletedOperations, + int YieldCount, + int OverrunCount, + int OversizedProgressCount, + int FailureCount, + double ElapsedMilliseconds, + string? LastStage, + StreamingWorkLimit LastLimit); + +/// +/// Read-only streaming scheduler facts published to lifecycle artifacts. +/// +public readonly record struct StreamingWorkDiagnostics( + StreamingWorkMeterSnapshot LastFrame, + int DeferredCompletions, + long DeferredAdoptedCpuBytes, + double OldestDeferredAgeMilliseconds, + int PendingPublications, + int PendingRetirements); + +/// +/// Single-thread, frame-scoped admission meter. Callers reserve a known cost +/// before an atomic operation and complete or fail that reservation afterward. +/// +public sealed class StreamingWorkMeter +{ + private readonly StreamingWorkBudget _budget; + private readonly Func _timestamp; + private readonly long _frequency; + private readonly long _start; + private StreamingWorkCost _used; + private int _operations; + private int _completed; + private int _yields; + private int _overruns; + private int _oversizedProgress; + private int _failures; + private bool _frameOverrunRecorded; + private bool _reservationActive; + private string? _activeStage; + private string? _lastStage; + private StreamingWorkLimit _lastLimit; + + public StreamingWorkMeter(StreamingWorkBudget budget) + : this(budget, Stopwatch.GetTimestamp, Stopwatch.Frequency) + { + } + + public StreamingWorkMeter( + StreamingWorkBudget budget, + Func timestamp, + long timestampFrequency) + { + ArgumentNullException.ThrowIfNull(timestamp); + if (timestampFrequency <= 0) + throw new ArgumentOutOfRangeException(nameof(timestampFrequency)); + budget.Validate(); + + _budget = budget; + _timestamp = timestamp; + _frequency = timestampFrequency; + _start = timestamp(); + } + + public StreamingWorkAdmission TryReserve( + StreamingWorkCost cost, + string stage) + { + cost.Validate(); + ArgumentException.ThrowIfNullOrWhiteSpace(stage); + if (_reservationActive) + throw new InvalidOperationException( + "The prior streaming operation has not completed."); + + StreamingWorkLimit limit = FindLimit(cost); + if (limit != StreamingWorkLimit.None && _operations != 0) + { + _yields++; + _lastStage = stage; + _lastLimit = limit; + return StreamingWorkAdmission.Yielded; + } + + _used = _used.Add(cost); + _operations++; + _reservationActive = true; + _activeStage = stage; + _lastStage = stage; + _lastLimit = limit; + if (limit != StreamingWorkLimit.None) + { + _oversizedProgress++; + return StreamingWorkAdmission.OversizedProgress; + } + + return StreamingWorkAdmission.Admitted; + } + + /// + /// E1 observation seam: records work the legacy scheduler already chose to + /// execute, while naming the budget decision that would have yielded. This + /// preserves execution until the explicit scheduler takes ownership in E2. + /// + public void ForceReserveObserved( + StreamingWorkCost cost, + string stage) + { + cost.Validate(); + ArgumentException.ThrowIfNullOrWhiteSpace(stage); + if (_reservationActive) + throw new InvalidOperationException( + "The prior streaming operation has not completed."); + + _used = _used.Add(cost); + _operations++; + _reservationActive = true; + _activeStage = stage; + _lastStage = stage; + } + + public void Complete() + { + EnsureReservation(); + _completed++; + _reservationActive = false; + _activeStage = null; + ObserveElapsedOverrun(); + } + + public void Fail() + { + EnsureReservation(); + _failures++; + _reservationActive = false; + _activeStage = null; + ObserveElapsedOverrun(); + } + + public void FinishFrame() + { + if (_reservationActive) + throw new InvalidOperationException( + "A streaming operation is still reserved at frame end."); + ObserveElapsedOverrun(); + } + + public StreamingWorkMeterSnapshot Snapshot + { + get + { + long now = _timestamp(); + return new StreamingWorkMeterSnapshot( + _used, + _operations, + _completed, + _yields, + _overruns, + _oversizedProgress, + _failures, + ElapsedMilliseconds(now), + _lastStage, + _lastLimit); + } + } + + private StreamingWorkLimit FindLimit(StreamingWorkCost cost) + { + if (ElapsedTicks(_timestamp()) >= _budget.MaxUpdateTime.Ticks) + return StreamingWorkLimit.Time; + if ((long)_used.CompletionAdmissions + cost.CompletionAdmissions + > _budget.MaxCompletionAdmissions) + { + return StreamingWorkLimit.CompletionAdmissions; + } + if (_used.AdoptedCpuBytes > _budget.MaxAdoptedCpuBytes - cost.AdoptedCpuBytes) + return StreamingWorkLimit.AdoptedCpuBytes; + if ((long)_used.EntityOperations + cost.EntityOperations + > _budget.MaxEntityOperations) + { + return StreamingWorkLimit.EntityOperations; + } + if (_used.GpuUploadBytes > _budget.MaxGpuUploadBytes - cost.GpuUploadBytes) + return StreamingWorkLimit.GpuUploadBytes; + if ((long)_used.GlRetireOperations + cost.GlRetireOperations + > _budget.MaxGlRetireOperations) + { + return StreamingWorkLimit.GlRetireOperations; + } + return StreamingWorkLimit.None; + } + + private void EnsureReservation() + { + if (!_reservationActive) + throw new InvalidOperationException( + "No streaming operation is reserved."); + } + + private void ObserveElapsedOverrun() + { + if (!_frameOverrunRecorded + && ElapsedTicks(_timestamp()) > _budget.MaxUpdateTime.Ticks) + { + _frameOverrunRecorded = true; + _overruns++; + _lastLimit = StreamingWorkLimit.Time; + _lastStage = _activeStage ?? _lastStage; + } + } + + private long ElapsedTicks(long timestamp) + { + long raw = Math.Max(0L, timestamp - _start); + return raw > long.MaxValue / TimeSpan.TicksPerSecond + ? long.MaxValue + : raw * TimeSpan.TicksPerSecond / _frequency; + } + + private double ElapsedMilliseconds(long timestamp) => + Math.Max(0L, timestamp - _start) * 1000.0 / _frequency; +} diff --git a/src/AcDream.App/Streaming/StreamingWorkBudgetOptions.cs b/src/AcDream.App/Streaming/StreamingWorkBudgetOptions.cs new file mode 100644 index 00000000..894f6004 --- /dev/null +++ b/src/AcDream.App/Streaming/StreamingWorkBudgetOptions.cs @@ -0,0 +1,121 @@ +using System.Globalization; + +namespace AcDream.App.Streaming; + +/// +/// Startup-time scheduling ceilings for update-thread streaming work. +/// These values limit work per frame; they never reduce the amount or +/// quality of content that eventually becomes resident. +/// +public sealed record StreamingWorkBudgetOptions( + double MaxUpdateMilliseconds, + int MaxCompletionAdmissions, + long MaxAdoptedCpuBytes, + int MaxEntityOperations, + long MaxGpuUploadBytes, + int MaxGlRetireOperations, + float DestinationReserveFraction) +{ + public const long MiB = 1024L * 1024L; + + public static StreamingWorkBudgetOptions Default { get; } = new( + MaxUpdateMilliseconds: 2.0, + MaxCompletionAdmissions: 64, + MaxAdoptedCpuBytes: 8 * MiB, + MaxEntityOperations: 256, + MaxGpuUploadBytes: 8 * MiB, + MaxGlRetireOperations: 64, + DestinationReserveFraction: 0.75f); + + internal static StreamingWorkBudgetOptions Parse( + Func env) + { + ArgumentNullException.ThrowIfNull(env); + StreamingWorkBudgetOptions defaults = Default; + return new StreamingWorkBudgetOptions( + MaxUpdateMilliseconds: ParsePositiveDouble( + env("ACDREAM_STREAM_WORK_MS"), + defaults.MaxUpdateMilliseconds), + MaxCompletionAdmissions: ParsePositiveInt( + env("ACDREAM_STREAM_WORK_COMPLETIONS"), + defaults.MaxCompletionAdmissions), + MaxAdoptedCpuBytes: ParseMiB( + env("ACDREAM_STREAM_WORK_CPU_MIB"), + defaults.MaxAdoptedCpuBytes), + MaxEntityOperations: ParsePositiveInt( + env("ACDREAM_STREAM_WORK_ENTITY_OPS"), + defaults.MaxEntityOperations), + MaxGpuUploadBytes: ParseMiB( + env("ACDREAM_STREAM_WORK_GPU_MIB"), + defaults.MaxGpuUploadBytes), + MaxGlRetireOperations: ParsePositiveInt( + env("ACDREAM_STREAM_WORK_GL_RETIRE_OPS"), + defaults.MaxGlRetireOperations), + DestinationReserveFraction: ParseReservePercent( + env("ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT"), + defaults.DestinationReserveFraction)); + } + + public StreamingWorkBudget ToBudget() => new( + TimeSpan.FromMilliseconds(MaxUpdateMilliseconds), + MaxCompletionAdmissions, + MaxAdoptedCpuBytes, + MaxEntityOperations, + MaxGpuUploadBytes, + MaxGlRetireOperations, + DestinationReserveFraction); + + private static double ParsePositiveDouble(string? value, double fallback) => + double.TryParse( + value, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out double parsed) + && double.IsFinite(parsed) + && parsed > 0 + ? parsed + : fallback; + + private static int ParsePositiveInt(string? value, int fallback) => + int.TryParse( + value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int parsed) + && parsed > 0 + ? parsed + : fallback; + + private static long ParseMiB(string? value, long fallback) + { + if (!long.TryParse( + value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out long mebibytes) + || mebibytes <= 0 + || mebibytes > long.MaxValue / MiB) + { + return fallback; + } + + return checked(mebibytes * MiB); + } + + private static float ParseReservePercent(string? value, float fallback) + { + if (!float.TryParse( + value, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out float percent) + || !float.IsFinite(percent) + || percent <= 0f + || percent >= 100f) + { + return fallback; + } + + return percent / 100f; + } +} diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs index ab600bba..bf9450af 100644 --- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -307,7 +307,7 @@ public sealed class WorldLifecycleAutomationControllerTests 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0L, 0, 0L, 0L, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, - default, 0d, 0d, null); + default, default, 0d, 0d, null); private static string NewDirectory() { diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index 0ea41521..234a91a3 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using AcDream.App; using AcDream.App.Rendering.Residency; +using AcDream.App.Streaming; namespace AcDream.App.Tests; @@ -20,6 +21,65 @@ public sealed class RuntimeOptionsTests _ => null); Assert.Equal(ResidencyBudgetOptions.Default, options.ResidencyBudgets); + Assert.Equal( + StreamingWorkBudgetOptions.Default, + options.StreamingWorkBudgets); + } + + [Fact] + public void StreamingWorkBudgetOverridesAreOneTypedProfile() + { + var values = new Dictionary + { + ["ACDREAM_STREAM_WORK_MS"] = "1.75", + ["ACDREAM_STREAM_WORK_COMPLETIONS"] = "31", + ["ACDREAM_STREAM_WORK_CPU_MIB"] = "6", + ["ACDREAM_STREAM_WORK_ENTITY_OPS"] = "144", + ["ACDREAM_STREAM_WORK_GPU_MIB"] = "5", + ["ACDREAM_STREAM_WORK_GL_RETIRE_OPS"] = "23", + ["ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT"] = "60", + }; + + RuntimeOptions options = RuntimeOptions.Parse( + "D:\\dat", + name => values.GetValueOrDefault(name)); + + Assert.Equal(1.75, options.StreamingWorkBudgets.MaxUpdateMilliseconds); + Assert.Equal(31, options.StreamingWorkBudgets.MaxCompletionAdmissions); + Assert.Equal( + 6 * StreamingWorkBudgetOptions.MiB, + options.StreamingWorkBudgets.MaxAdoptedCpuBytes); + Assert.Equal(144, options.StreamingWorkBudgets.MaxEntityOperations); + Assert.Equal( + 5 * StreamingWorkBudgetOptions.MiB, + options.StreamingWorkBudgets.MaxGpuUploadBytes); + Assert.Equal(23, options.StreamingWorkBudgets.MaxGlRetireOperations); + Assert.Equal(0.60f, options.StreamingWorkBudgets.DestinationReserveFraction); + } + + [Theory] + [InlineData("0")] + [InlineData("-1")] + [InlineData("NaN")] + [InlineData("Infinity")] + [InlineData("bad")] + public void InvalidStreamingWorkValuesFallBackIndependently(string value) + { + RuntimeOptions options = RuntimeOptions.Parse( + "D:\\dat", + name => name switch + { + "ACDREAM_STREAM_WORK_MS" => value, + "ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT" => value, + _ => null, + }); + + Assert.Equal( + StreamingWorkBudgetOptions.Default.MaxUpdateMilliseconds, + options.StreamingWorkBudgets.MaxUpdateMilliseconds); + Assert.Equal( + StreamingWorkBudgetOptions.Default.DestinationReserveFraction, + options.StreamingWorkBudgets.DestinationReserveFraction); } [Fact] diff --git a/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs new file mode 100644 index 00000000..716f22fe --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs @@ -0,0 +1,291 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; +using AcDream.Core.Terrain; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Tests.Streaming; + +public sealed class StreamingWorkBudgetTests +{ + private static StreamingWorkBudget Budget( + double milliseconds = 2, + int completions = 4, + long cpuBytes = 100, + int entityOperations = 8, + long gpuBytes = 100, + int retireOperations = 4) => new( + TimeSpan.FromMilliseconds(milliseconds), + completions, + cpuBytes, + entityOperations, + gpuBytes, + retireOperations, + 0.75f); + + [Fact] + public void MeterYieldsBeforeSecondOperationThatExceedsAnyDimension() + { + long now = 0; + var meter = new StreamingWorkMeter( + Budget(), + () => now, + timestampFrequency: 1_000); + + Assert.Equal( + StreamingWorkAdmission.Admitted, + meter.TryReserve( + new StreamingWorkCost( + CompletionAdmissions: 2, + AdoptedCpuBytes: 60, + EntityOperations: 3, + GpuUploadBytes: 40, + GlRetireOperations: 1), + "first")); + meter.Complete(); + + Assert.Equal( + StreamingWorkAdmission.Yielded, + meter.TryReserve( + new StreamingWorkCost(AdoptedCpuBytes: 41), + "second")); + + StreamingWorkMeterSnapshot snapshot = meter.Snapshot; + Assert.Equal(1, snapshot.Operations); + Assert.Equal(1, snapshot.CompletedOperations); + Assert.Equal(1, snapshot.YieldCount); + Assert.Equal(StreamingWorkLimit.AdoptedCpuBytes, snapshot.LastLimit); + Assert.Equal("second", snapshot.LastStage); + } + + [Fact] + public void FirstOversizedOperationProgressesAndNamesTheOversize() + { + var meter = new StreamingWorkMeter( + Budget(completions: 1), + static () => 0, + timestampFrequency: 1_000); + + Assert.Equal( + StreamingWorkAdmission.OversizedProgress, + meter.TryReserve( + new StreamingWorkCost(CompletionAdmissions: 2), + "oversized")); + meter.Complete(); + + StreamingWorkMeterSnapshot snapshot = meter.Snapshot; + Assert.Equal(1, snapshot.OversizedProgressCount); + Assert.Equal( + StreamingWorkLimit.CompletionAdmissions, + snapshot.LastLimit); + } + + [Fact] + public void MeterUsesInjectedMonotonicClockAndRecordsOverrunAndFailure() + { + long now = 0; + var meter = new StreamingWorkMeter( + Budget(milliseconds: 2), + () => now, + timestampFrequency: 1_000); + + Assert.Equal( + StreamingWorkAdmission.Admitted, + meter.TryReserve(new StreamingWorkCost(), "slow")); + now = 3; + meter.Fail(); + + StreamingWorkMeterSnapshot snapshot = meter.Snapshot; + Assert.Equal(3, snapshot.ElapsedMilliseconds); + Assert.Equal(1, snapshot.OverrunCount); + Assert.Equal(1, snapshot.FailureCount); + Assert.Equal(0, snapshot.CompletedOperations); + Assert.Equal(StreamingWorkLimit.Time, snapshot.LastLimit); + } + + [Fact] + public void MeterRejectsReentrantReservationAndNegativeCost() + { + var meter = new StreamingWorkMeter( + Budget(), + static () => 0, + timestampFrequency: 1_000); + + meter.TryReserve(new StreamingWorkCost(), "first"); + Assert.Throws(() => + meter.TryReserve(new StreamingWorkCost(), "reentrant")); + meter.Complete(); + Assert.Throws(() => + meter.TryReserve( + new StreamingWorkCost(AdoptedCpuBytes: -1), + "negative")); + Assert.Throws(() => + new StreamingWorkMeter( + default, + static () => 0, + timestampFrequency: 1_000)); + } + + [Fact] + public void CompletionCostChargesExactArraysAndDeterministicLogicalEntries() + { + const uint landblockId = 0xA9B4FFFFu; + var first = Entity(1, meshRefs: 2); + var second = Entity(2, meshRefs: 1); + var cell = new LoadedCell + { + CellId = 0xA9B40100u, + Portals = + [ + new CellPortalInfo(1, 2, 3, 4), + new CellPortalInfo(5, 6, 7, 8), + ], + ClipPlanes = [new PortalClipPlane(), new PortalClipPlane()], + PortalPolygons = + [ + new Vector3[3], + new Vector3[4], + ], + VisibleCells = new uint[3], + }; + var shell = new EnvCellShellPlacement( + cell.CellId, + 1, + 2, + 3, + ImmutableArray.Create(4, 5, 6), + Vector3.Zero, + Quaternion.Identity, + Matrix4x4.Identity, + new WbBoundingBox(Vector3.Zero, Vector3.One), + new WbBoundingBox(Vector3.Zero, Vector3.One)); + var physics = new PhysicsDatBundle( + new LandBlockInfo(), + new Dictionary { [1] = new EnvCell() }, + new Dictionary + { + [2] = new DatReaderWriter.DBObjs.Environment(), + }, + new Dictionary { [3] = new Setup() }, + new Dictionary { [4] = new GfxObj() }); + var build = new LandblockBuild( + new LoadedLandblock( + landblockId, + new LandBlock(), + [first, second], + physics), + new EnvCellLandblockBuild(landblockId, [cell], [shell])); + var mesh = new LandblockMeshData( + new TerrainVertex[2], + new uint[3]); + + LandblockStreamCostEstimate estimate = + LandblockStreamResultCost.Estimate(build, mesh); + + Assert.Equal(92, estimate.TerrainPayloadBytes); + // MeshRef is 80 bytes on the supported x64 runtime: the Matrix4x4 + // retains its 16-byte alignment after the 4-byte GfxObj id. + Assert.Equal(586, estimate.Work.AdoptedCpuBytes); + Assert.Equal(1, estimate.Work.CompletionAdmissions); + Assert.Equal(2, estimate.Work.EntityOperations); + Assert.Equal(92, estimate.Work.GpuUploadBytes); + Assert.Equal(3, estimate.MeshReferences); + Assert.Equal(1, estimate.VisibilityCells); + Assert.Equal(1, estimate.EnvCellShells); + Assert.Equal(2, estimate.PortalConnections); + Assert.Equal(7, estimate.PortalPolygonVertices); + Assert.Equal(1, estimate.PhysicsEnvCells); + Assert.Equal(1, estimate.PhysicsEnvironments); + Assert.Equal(1, estimate.PhysicsSetups); + Assert.Equal(1, estimate.PhysicsGfxObjects); + } + + [Fact] + public void NonPayloadCompletionCostsOnlyOneAdmission() + { + LandblockStreamCostEstimate estimate = + LandblockStreamResultCost.Estimate( + new LandblockStreamResult.Failed(0x1234FFFFu, "expected")); + + Assert.Equal( + new StreamingWorkCost(CompletionAdmissions: 1), + estimate.Work); + Assert.Equal(0, estimate.TerrainPayloadBytes); + } + + [Fact] + public void LegacySchedulerPublishesShadowBudgetAndRetainedBacklogFacts() + { + uint center = StreamingRegion.EncodeLandblockIdForTest(32, 32); + uint neighbor = StreamingRegion.EncodeLandblockIdForTest(32, 33); + var outbox = new Queue( + [ + Loaded(center), + Loaded(neighbor), + ]); + var controller = new StreamingController( + enqueueLoad: static (_, _) => { }, + enqueueUnload: static _ => { }, + drainCompletions: max => + { + var drained = new List(max); + while (drained.Count < max && outbox.TryDequeue(out var result)) + drained.Add(result); + return drained; + }, + applyTerrain: static (_, _) => { }, + state: new GpuWorldState(), + nearRadius: 1, + farRadius: 1) + { + MaxCompletionsPerFrame = 1, + }; + + controller.Tick(32, 32); + + StreamingWorkDiagnostics first = controller.WorkDiagnostics; + Assert.Equal(1, first.DeferredCompletions); + Assert.Equal(44, first.DeferredAdoptedCpuBytes); + Assert.True(first.OldestDeferredAgeMilliseconds >= 0); + Assert.Equal(1, first.LastFrame.Operations); + Assert.Equal(1, first.LastFrame.CompletedOperations); + Assert.Equal(44, first.LastFrame.Used.AdoptedCpuBytes); + + controller.Tick(32, 32); + + StreamingWorkDiagnostics second = controller.WorkDiagnostics; + Assert.Equal(0, second.DeferredCompletions); + Assert.Equal(0, second.DeferredAdoptedCpuBytes); + Assert.Equal(0, second.OldestDeferredAgeMilliseconds); + Assert.Equal(1, second.LastFrame.CompletedOperations); + } + + private static WorldEntity Entity(uint id, int meshRefs) + { + var refs = new MeshRef[meshRefs]; + for (int i = 0; i < refs.Length; i++) + refs[i] = new MeshRef((uint)(0x01000000 + i), Matrix4x4.Identity); + return new WorldEntity + { + Id = id, + SourceGfxObjOrSetupId = 0x01000000u + id, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = refs, + }; + } + + private static LandblockStreamResult.Loaded Loaded(uint landblockId) => new( + landblockId, + LandblockStreamTier.Near, + new LoadedLandblock( + landblockId, + new LandBlock(), + Array.Empty()), + new LandblockMeshData( + new TerrainVertex[1], + new uint[1])); +}