acdream/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs
Erik ac45cb1bd7 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)
2026-07-24 17:26:34 +02:00

291 lines
9.9 KiB
C#

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