feat(streaming): establish cost budget ledger

Add the validated frame-work profile, deterministic completion charges, and shadow admission meter before enforcing the scheduler in Slice E2. Lifecycle artifacts now expose elapsed work, would-yield limits, backlog bytes/age, and pending owner ledgers without changing accepted execution.

Tests: dotnet build AcDream.slnx -c Release --no-restore; dotnet test AcDream.slnx -c Release --no-build --no-restore (8124 passed, 5 skipped)
This commit is contained in:
Erik 2026-07-24 17:26:34 +02:00
parent 2945896a6f
commit ac45cb1bd7
14 changed files with 1280 additions and 71 deletions

View file

@ -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()
{

View file

@ -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<string, string?>
{
["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]

View file

@ -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<InvalidOperationException>(() =>
meter.TryReserve(new StreamingWorkCost(), "reentrant"));
meter.Complete();
Assert.Throws<ArgumentOutOfRangeException>(() =>
meter.TryReserve(
new StreamingWorkCost(AdoptedCpuBytes: -1),
"negative"));
Assert.Throws<ArgumentException>(() =>
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<ushort>(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<uint, EnvCell> { [1] = new EnvCell() },
new Dictionary<uint, DatReaderWriter.DBObjs.Environment>
{
[2] = new DatReaderWriter.DBObjs.Environment(),
},
new Dictionary<uint, Setup> { [3] = new Setup() },
new Dictionary<uint, GfxObj> { [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<LandblockStreamResult>(
[
Loaded(center),
Loaded(neighbor),
]);
var controller = new StreamingController(
enqueueLoad: static (_, _) => { },
enqueueUnload: static _ => { },
drainCompletions: max =>
{
var drained = new List<LandblockStreamResult>(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<WorldEntity>()),
new LandblockMeshData(
new TerrainVertex[1],
new uint[1]));
}