feat(diagnostics): complete residency pressure ledger

Complete Slice D4 by adding aggregate lifecycle occupancy and traffic facts, validating physical source reports, and including decoded audio under the typed startup budget. Exercise every domain under forced pressure and retain the real cache/fence convergence gates.
This commit is contained in:
Erik 2026-07-24 16:40:17 +02:00
parent f2644d42c2
commit 1853a57c12
15 changed files with 297 additions and 15 deletions

View file

@ -1,6 +1,6 @@
# Modern Runtime Slice D — Typed Asset Handles and Unified Residency
**Status:** active — D1 through D3 complete
**Status:** active — D1 through D4 complete
**Program:** `docs/plans/2026-07-24-modern-runtime-architecture.md`
**Baseline:** Slice C closeout commit `a564c4b7`
**Behavior contract:** no visual-quality or retail-behavior change
@ -173,6 +173,7 @@ contains byte/count ceilings with the current production values as defaults:
- composite arrays: 128 MiB physical / 64 MiB unowned;
- standalone particle arrays: 32 MiB / 256;
- decoded animations: 64 MiB / 512;
- decoded audio: 32 MiB;
- deferred-alpha retained CPU scratch: 16 MiB aggregate with per-owner floors.
Environment overrides are diagnostic/startup inputs and reject zero, negative,
@ -248,6 +249,19 @@ current source, so no replacement cache was invented.
drain fence release, and prove convergence.
- Assert manager and physical-owner accounting return to zero at teardown.
**Complete 2026-07-24.** The lifecycle artifact now serializes validated
per-domain rows plus aggregate entry/owner, budget, allocator
capacity/usage/fragmentation, traffic, committed CPU, mapped-address-space, and
physical GPU totals. The final omitted bounded owner, decoded audio, now
receives its startup budget through `RuntimeOptions` and reports decoded bytes
and hit/miss/eviction traffic beside animations and the render caches. A
deterministic all-domain pressure matrix exceeds every reported ceiling and
proves zero-charge convergence. The focused physical-owner suites separately
exercise prepared-mesh/staging rejection, composite and standalone texture
eviction, fence-delayed release, global-arena range retirement, animation/audio
LRU pressure, and alpha-scratch convergence. Release build and the complete
8,111-test / 5-skip solution gate are green.
### D5 — Connected gate
- Run capped, uncapped, and dense connected routes against Slice C.

View file

@ -113,7 +113,9 @@ internal interface IContentEffectsAudioCompositionFactory
EntityEffectPoseRegistry poses);
TranslucencyHookSink CreateTranslucencySink(TranslucencyFadeManager fades);
AnimationHookRegistrationSet CreateHookRegistrations(AnimationHookRouter router);
DatSoundCache CreateSoundCache(IDatReaderWriter dats);
DatSoundCache CreateSoundCache(
IDatReaderWriter dats,
long maximumDecodedBytes);
OpenAlAudioEngine CreateAudioEngine();
DictionaryEntitySoundTable CreateEntitySoundTables();
AudioHookSink CreateAudioSink(
@ -204,7 +206,10 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
AnimationHookRouter router) =>
new(router);
public DatSoundCache CreateSoundCache(IDatReaderWriter dats) => new(dats);
public DatSoundCache CreateSoundCache(
IDatReaderWriter dats,
long maximumDecodedBytes) =>
new(dats, maximumDecodedBytes);
public OpenAlAudioEngine CreateAudioEngine() => new();
@ -426,7 +431,9 @@ internal sealed class ContentEffectsAudioCompositionPhase :
engineLease;
try
{
DatSoundCache cache = _factory.CreateSoundCache(dats);
DatSoundCache cache = _factory.CreateSoundCache(
dats,
_dependencies.ResidencyBudgets.AudioBytes);
Fault(ContentEffectsAudioCompositionPoint.SoundCacheCreated);
engineLease = audioScope.Acquire(
"OpenAL audio engine",

View file

@ -5,6 +5,7 @@ using AcDream.App.Rendering.Residency;
using AcDream.App.World;
using AcDream.Content;
using AcDream.Content.Vfx;
using AcDream.Core.Audio;
using AcDream.Core.Physics;
using AcDream.Core.Terrain;
using AcDream.UI.Abstractions.Settings;
@ -119,7 +120,8 @@ internal interface IWorldRenderCompositionFactory
WbMeshAdapter meshes,
TextureCache textures,
IPreparedAssetSource preparedAssets,
IAnimationLoader animations);
IAnimationLoader animations,
DatSoundCache? audio);
SamplerCache CreateSamplerCache(GL gl);
void Release(IDisposable resource);
}
@ -299,7 +301,8 @@ internal sealed class RetailWorldRenderCompositionFactory
WbMeshAdapter meshes,
TextureCache textures,
IPreparedAssetSource preparedAssets,
IAnimationLoader animations)
IAnimationLoader animations,
DatSoundCache? audio)
{
meshes.RegisterResidencySources(manager);
textures.RegisterResidencySources(manager);
@ -328,9 +331,30 @@ internal sealed class RetailWorldRenderCompositionFactory
DecodedBytes:
diagnostics.EstimatedBytes),
BudgetBytes: diagnostics.BudgetBytes,
Hits: diagnostics.Stats.Hits,
Misses: diagnostics.Stats.Misses,
Evictions: diagnostics.Stats.Evictions);
}));
}
if (audio is not null)
{
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.Audio,
() =>
{
DatSoundCacheDiagnostics diagnostics = audio.Diagnostics;
return new ResidencyDomainSnapshot(
ResidencyDomain.Audio,
EntryCount: diagnostics.CachedWaveCount,
OwnerCount: 0,
Charges: new ResidencyCharges(
DecodedBytes: diagnostics.ResidentWaveBytes),
BudgetBytes: diagnostics.BudgetBytes,
Hits: diagnostics.Hits,
Misses: diagnostics.Misses,
Evictions: diagnostics.Evictions);
}));
}
}
public SamplerCache CreateSamplerCache(GL gl) => new(gl);
@ -517,7 +541,8 @@ internal sealed class WorldRenderCompositionPhase
meshAdapter,
textureCache,
content.PreparedAssets,
content.AnimationLoader);
content.AnimationLoader,
content.Audio?.SoundCache);
scope.Complete();
_dependencies.Log(

View file

@ -164,12 +164,41 @@ internal readonly record struct ResidencyDomainSnapshot(
long CapacityBytes = 0,
long UsedBytes = 0,
long LargestFreeBytes = 0,
long Hits = 0,
long Misses = 0,
long Evictions = 0)
{
public long FreeBytes => Math.Max(0, CapacityBytes - UsedBytes);
public long FragmentedFreeBytes =>
Math.Max(0, FreeBytes - LargestFreeBytes);
public void Validate()
{
if (!Enum.IsDefined(Domain))
throw new ArgumentOutOfRangeException(nameof(Domain));
ArgumentOutOfRangeException.ThrowIfNegative(EntryCount);
ArgumentOutOfRangeException.ThrowIfNegative(OwnerCount);
Charges.Validate();
ArgumentOutOfRangeException.ThrowIfNegative(BudgetBytes);
ArgumentOutOfRangeException.ThrowIfNegative(CapacityBytes);
ArgumentOutOfRangeException.ThrowIfNegative(UsedBytes);
ArgumentOutOfRangeException.ThrowIfNegative(LargestFreeBytes);
ArgumentOutOfRangeException.ThrowIfNegative(Hits);
ArgumentOutOfRangeException.ThrowIfNegative(Misses);
ArgumentOutOfRangeException.ThrowIfNegative(Evictions);
if (UsedBytes > CapacityBytes && CapacityBytes != 0)
{
throw new InvalidOperationException(
$"{Domain} residency uses {UsedBytes} bytes from "
+ $"{CapacityBytes} bytes of physical capacity.");
}
if (LargestFreeBytes > CapacityBytes)
{
throw new InvalidOperationException(
$"{Domain} largest free range exceeds physical capacity.");
}
}
}
internal readonly record struct ResidencySnapshot(
@ -177,7 +206,35 @@ internal readonly record struct ResidencySnapshot(
ResidencyCharges TotalCharges)
{
public ResidencyDomainSnapshot Get(ResidencyDomain domain) =>
Domains.FirstOrDefault(snapshot => snapshot.Domain == domain);
Domains?.FirstOrDefault(snapshot => snapshot.Domain == domain)
?? default;
public int TotalEntries =>
Domains?.Sum(static snapshot => snapshot.EntryCount) ?? 0;
public int TotalOwners =>
Domains?.Sum(static snapshot => snapshot.OwnerCount) ?? 0;
public long TotalBudgetBytes =>
Domains?.Sum(static snapshot => snapshot.BudgetBytes) ?? 0;
public long TotalCapacityBytes =>
Domains?.Sum(static snapshot => snapshot.CapacityBytes) ?? 0;
public long TotalUsedBytes =>
Domains?.Sum(static snapshot => snapshot.UsedBytes) ?? 0;
public long TotalFragmentedFreeBytes =>
Domains?.Sum(static snapshot => snapshot.FragmentedFreeBytes) ?? 0;
public long TotalHits =>
Domains?.Sum(static snapshot => snapshot.Hits) ?? 0;
public long TotalMisses =>
Domains?.Sum(static snapshot => snapshot.Misses) ?? 0;
public long TotalEvictions =>
Domains?.Sum(static snapshot => snapshot.Evictions) ?? 0;
}
internal interface IResidencyDomainSource

View file

@ -17,6 +17,7 @@ internal sealed class DelegateResidencyDomainSource(
throw new InvalidOperationException(
$"Residency source {Domain} returned {snapshot.Domain}.");
}
snapshot.Validate();
return snapshot;
}
}

View file

@ -19,6 +19,7 @@ public sealed record ResidencyBudgetOptions(
int StandaloneUnownedEntries,
long AnimationBytes,
int AnimationEntries,
long AudioBytes,
long AlphaScratchBytes)
{
public const long MiB = 1024L * 1024L;
@ -36,6 +37,7 @@ public sealed record ResidencyBudgetOptions(
StandaloneUnownedEntries: 256,
AnimationBytes: 64 * MiB,
AnimationEntries: 512,
AudioBytes: 32 * MiB,
AlphaScratchBytes: 16 * MiB);
internal static ResidencyBudgetOptions Parse(
@ -80,6 +82,9 @@ public sealed record ResidencyBudgetOptions(
AnimationEntries: ParseCount(
env("ACDREAM_RESIDENCY_ANIMATION_ENTRIES"),
defaults.AnimationEntries),
AudioBytes: ParseMiB(
env("ACDREAM_RESIDENCY_AUDIO_MIB"),
defaults.AudioBytes),
AlphaScratchBytes: ParseMiB(
env("ACDREAM_RESIDENCY_ALPHA_SCRATCH_MIB"),
defaults.AlphaScratchBytes));
@ -110,4 +115,3 @@ public sealed record ResidencyBudgetOptions(
? count
: fallback;
}

View file

@ -638,6 +638,7 @@ internal sealed class ResidencyManager
LogicalBytes: current.LogicalBytes,
CpuPreparedBytes: current.CpuPreparedBytes,
DecodedBytes: current.DecodedBytes,
ScratchBytes: current.ScratchBytes,
PinnedBytes: current.PinnedBytes,
RetiringBytes: checked(
current.GpuRequestedBytes

View file

@ -1316,14 +1316,20 @@ namespace AcDream.App.Rendering.Wb
return bytes;
}
internal ResidencyDomainSnapshot CapturePreparedMeshResidency() =>
new(
internal ResidencyDomainSnapshot CapturePreparedMeshResidency()
{
CacheStats stats = _cpuMeshCache.Stats;
return new ResidencyDomainSnapshot(
ResidencyDomain.PreparedMeshCpu,
EntryCount: _cpuMeshCache.Count,
OwnerCount: 0,
Charges: new ResidencyCharges(
CpuPreparedBytes: _cpuMeshCache.ResidentBytes),
BudgetBytes: _cpuMeshCache.ByteCapacity);
BudgetBytes: _cpuMeshCache.ByteCapacity,
Hits: stats.Hits,
Misses: stats.Misses,
Evictions: stats.Evictions);
}
internal ResidencyDomainSnapshot CaptureStagingResidency() =>
new(

View file

@ -8,6 +8,14 @@ using AcDream.Core.Content;
namespace AcDream.Core.Audio;
public readonly record struct DatSoundCacheDiagnostics(
int CachedWaveCount,
long ResidentWaveBytes,
long BudgetBytes,
long Hits,
long Misses,
long Evictions);
/// <summary>
/// DatCollection-backed cache of decoded waves + SoundTable lookups.
///
@ -74,6 +82,9 @@ public sealed class DatSoundCache
private readonly LinkedList<WaveEntry> _waveLru = new();
private readonly long _maxWaveBytes;
private long _residentWaveBytes;
private long _hits;
private long _misses;
private long _evictions;
private sealed record WaveEntry(uint WaveId, WaveData Wave, long Bytes);
@ -86,7 +97,7 @@ public sealed class DatSoundCache
/// Test seam for pinning a smaller byte budget so eviction can be
/// exercised deterministically without allocating tens of megabytes.
/// </summary>
internal DatSoundCache(IDatObjectSource dats, long maxWaveBytes)
public DatSoundCache(IDatObjectSource dats, long maxWaveBytes)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentOutOfRangeException.ThrowIfLessThan(maxWaveBytes, 1);
@ -105,13 +116,19 @@ public sealed class DatSoundCache
{
if (_waveEntries.TryGetValue(waveId, out var node))
{
Interlocked.Increment(ref _hits);
Touch(node);
return node.Value.Wave;
}
}
if (_negativeWaveIds.ContainsKey(waveId))
{
Interlocked.Increment(ref _hits);
return null;
}
Interlocked.Increment(ref _misses);
Lazy<WaveData?> lazy = _inflight.GetOrAdd(
waveId,
@ -171,6 +188,23 @@ public sealed class DatSoundCache
/// </summary>
public int CachedSoundTableCount => _tables.Count;
public DatSoundCacheDiagnostics Diagnostics
{
get
{
lock (_gate)
{
return new DatSoundCacheDiagnostics(
_waveEntries.Count,
_residentWaveBytes,
_maxWaveBytes,
Interlocked.Read(ref _hits),
Interlocked.Read(ref _misses),
Interlocked.Read(ref _evictions));
}
}
}
private WaveData? DecodeUncached(uint waveId)
{
var wave = _dats.Get<Wave>(waveId);
@ -225,5 +259,6 @@ public sealed class DatSoundCache
_waveLru.Remove(node);
_waveEntries.Remove(node.Value.WaveId);
_residentWaveBytes -= node.Value.Bytes;
Interlocked.Increment(ref _evictions);
}
}

View file

@ -433,7 +433,15 @@ public sealed class ContentEffectsAudioCompositionTests
EntityEffectPoseRegistry poses) => new(lighting, poses);
public TranslucencyHookSink CreateTranslucencySink(
TranslucencyFadeManager fades) => new(fades);
public DatSoundCache CreateSoundCache(IDatReaderWriter dats) => new(dats);
public DatSoundCache CreateSoundCache(
IDatReaderWriter dats,
long maximumDecodedBytes)
{
Assert.Equal(
ResidencyBudgetOptions.Default.AudioBytes,
maximumDecodedBytes);
return new DatSoundCache(dats, maximumDecodedBytes);
}
public OpenAlAudioEngine CreateAudioEngine()
{

View file

@ -308,7 +308,8 @@ public sealed class WorldRenderCompositionTests
WbMeshAdapter meshes,
TextureCache textures,
IPreparedAssetSource preparedAssets,
IAnimationLoader animations)
IAnimationLoader animations,
AcDream.Core.Audio.DatSoundCache? audio)
{
RegisteredResidency = manager;
}

View file

@ -1,6 +1,7 @@
using System.Text.Json;
using AcDream.App.Diagnostics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Residency;
using AcDream.App.Streaming;
using AcDream.App.UI.Testing;
using SixLabors.ImageSharp;
@ -114,6 +115,20 @@ public sealed class WorldLifecycleAutomationControllerTests
LoadedLandblocks = 1,
WorldEntities = 42,
TrackedGpuBytes = 1234,
Residency = new ResidencySnapshot(
[
new ResidencyDomainSnapshot(
ResidencyDomain.Animations,
EntryCount: 2,
OwnerCount: 0,
Charges: new ResidencyCharges(
DecodedBytes: 1024),
BudgetBytes: 4096,
Hits: 8,
Misses: 3,
Evictions: 1),
],
new ResidencyCharges(DecodedBytes: 1024)),
};
var screenshots = new FrameScreenshotController(
(_, _) => [0, 0, 0, 255],
@ -151,6 +166,18 @@ public sealed class WorldLifecycleAutomationControllerTests
Assert.True(json.RootElement.GetProperty("render").GetProperty("presentation")
.GetProperty("screenshotCaptured").GetBoolean());
Assert.Equal(42, json.RootElement.GetProperty("resources").GetProperty("worldEntities").GetInt32());
JsonElement residency = json.RootElement
.GetProperty("resources")
.GetProperty("residency");
Assert.Equal(
4096,
residency.GetProperty("totalBudgetBytes").GetInt64());
Assert.Equal(8, residency.GetProperty("totalHits").GetInt64());
Assert.Equal(
1024,
residency.GetProperty("totalCharges")
.GetProperty("decodedBytes")
.GetInt64());
Assert.True(File.Exists(Path.Combine(directory, "checkpoint-dungeon.json")));
}
finally

View file

@ -393,6 +393,90 @@ public sealed class ResidencyManagerTests
16,
snapshot.Get(ResidencyDomain.StandaloneTextures)
.FragmentedFreeBytes);
Assert.Equal(256, snapshot.TotalBudgetBytes);
Assert.Equal(128, snapshot.TotalCapacityBytes);
Assert.Equal(96, snapshot.TotalUsedBytes);
Assert.Equal(16, snapshot.TotalFragmentedFreeBytes);
Assert.Equal(3, snapshot.TotalEntries);
Assert.Equal(2, snapshot.TotalOwners);
}
[Fact]
public void ForcedPressureAcrossEveryDomainConvergesToZero()
{
const long budget = 64;
var manager = new ResidencyManager();
var current = new Dictionary<
ResidencyDomain,
ResidencyDomainSnapshot>();
foreach (ResidencyDomain domain in Enum.GetValues<ResidencyDomain>())
{
current[domain] = new ResidencyDomainSnapshot(
domain,
EntryCount: 2,
OwnerCount: 1,
Charges: new ResidencyCharges(
CpuPreparedBytes: budget * 2),
BudgetBytes: budget,
Hits: 3,
Misses: 2,
Evictions: 1);
ResidencyDomain capturedDomain = domain;
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
capturedDomain,
() => current[capturedDomain]));
}
ResidencySnapshot pressured = manager.CaptureSnapshot();
Assert.Equal(
Enum.GetValues<ResidencyDomain>().Length,
pressured.Domains.Count);
Assert.All(
pressured.Domains,
domain => Assert.True(
domain.Charges.CommittedCpuBytes > domain.BudgetBytes));
Assert.Equal(
budget * 2 * pressured.Domains.Count,
pressured.TotalCharges.CommittedCpuBytes);
foreach (ResidencyDomain domain in Enum.GetValues<ResidencyDomain>())
{
current[domain] = new ResidencyDomainSnapshot(
domain,
EntryCount: 0,
OwnerCount: 0,
Charges: ResidencyCharges.Zero,
BudgetBytes: budget,
Hits: 3,
Misses: 2,
Evictions: 3);
}
ResidencySnapshot drained = manager.CaptureSnapshot();
Assert.True(drained.TotalCharges.IsZero);
Assert.Equal(0, drained.TotalEntries);
Assert.Equal(0, drained.TotalOwners);
Assert.Equal(budget * drained.Domains.Count, drained.TotalBudgetBytes);
Assert.Equal(30, drained.TotalEvictions);
}
[Fact]
public void DomainSourceRejectsImpossibleAllocatorFacts()
{
var source = new DelegateResidencyDomainSource(
ResidencyDomain.GlobalMeshArena,
() => new ResidencyDomainSnapshot(
ResidencyDomain.GlobalMeshArena,
EntryCount: 1,
OwnerCount: 0,
Charges: ResidencyCharges.Zero,
CapacityBytes: 64,
UsedBytes: 65));
Assert.Throws<InvalidOperationException>(
() => source.CaptureResidency());
}
[Fact]

View file

@ -31,6 +31,7 @@ public sealed class RuntimeOptionsTests
["ACDREAM_RESIDENCY_MESH_UNOWNED_ENTRIES"] = "72",
["ACDREAM_RESIDENCY_ANIMATION_MIB"] = "48",
["ACDREAM_RESIDENCY_ANIMATION_ENTRIES"] = "300",
["ACDREAM_RESIDENCY_AUDIO_MIB"] = "24",
};
RuntimeOptions options = RuntimeOptions.Parse(
@ -47,6 +48,9 @@ public sealed class RuntimeOptionsTests
48 * ResidencyBudgetOptions.MiB,
options.ResidencyBudgets.AnimationBytes);
Assert.Equal(300, options.ResidencyBudgets.AnimationEntries);
Assert.Equal(
24 * ResidencyBudgetOptions.MiB,
options.ResidencyBudgets.AudioBytes);
}
[Theory]

View file

@ -110,6 +110,14 @@ public sealed class DatSoundCacheTests
Assert.NotNull(b2);
Assert.NotSame(b1, b2); // evicted entries decode fresh on next request
Assert.Equal(2, dats.GetCallCount(2));
DatSoundCacheDiagnostics diagnostics = cache.Diagnostics;
Assert.Equal(2, diagnostics.CachedWaveCount);
Assert.Equal(300, diagnostics.ResidentWaveBytes);
Assert.Equal(300, diagnostics.BudgetBytes);
Assert.True(diagnostics.Hits >= 3);
Assert.True(diagnostics.Misses >= 4);
Assert.True(diagnostics.Evictions >= 2);
}
[Fact]