From f2644d42c24e778a93c74f631ba60e21dc5dfbe6 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Jul 2026 16:34:28 +0200 Subject: [PATCH] perf(render): bound animation and alpha scratch residency Complete Slice D3 by replacing the unbounded animation dictionary with a concurrent byte/count LRU and by putting the three retail alpha scratch owners behind one typed aggregate budget. Preserve immediate growth and draw order while reclaiming one-frame density spikes after sustained under-use. Close stale bounds-cache issue evidence without inventing a cache. --- docs/ISSUES.md | 26 +- ...odern-runtime-slice-d-unified-residency.md | 15 +- .../ContentEffectsAudioComposition.cs | 22 +- .../LivePresentationComposition.cs | 23 +- .../Composition/WorldRenderComposition.cs | 30 ++- src/AcDream.App/Rendering/GameWindow.cs | 8 +- src/AcDream.App/Rendering/ParticleRenderer.cs | 39 ++- .../Residency/AssetResidencyTypes.cs | 4 + .../RetainedScratchCapacityPolicy.cs | 105 ++++++++ src/AcDream.App/Rendering/RetailAlphaQueue.cs | 71 ++++++ .../Rendering/Wb/WbDrawDispatcher.cs | 94 +++++++- .../Vfx/RetailAnimationLoader.cs | 224 +++++++++++++++++- .../ContentEffectsAudioCompositionTests.cs | 16 +- .../WorldRenderCompositionTests.cs | 4 +- .../Residency/ResidencyManagerTests.cs | 14 ++ .../RetainedScratchCapacityPolicyTests.cs | 86 +++++++ .../Rendering/RetailAlphaQueueTests.cs | 25 ++ .../Vfx/RetailDatLoaderTests.cs | 85 +++++++ 18 files changed, 857 insertions(+), 34 deletions(-) create mode 100644 src/AcDream.App/Rendering/Residency/RetainedScratchCapacityPolicy.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Residency/RetainedScratchCapacityPolicyTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index e9fbc33c..0b987b1e 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -360,7 +360,7 @@ ack-per-packet). ## #240 — RetailAnimationLoader caches every parsed Animation forever -**Status:** OPEN +**Status:** DONE — 2026-07-24, Modern Runtime Slice D3 **Severity:** MEDIUM **Filed:** 2026-07-24 **Component:** content / animation @@ -373,12 +373,14 @@ creature/object motion tables grows this monotonically — same class as the audio caches bounded on 2026-07-24, and structurally incapable of the plateau behavior the audit measured for entities/particles. -**Root cause / status:** 2026-07-24 audit review finding. Bound with the -same LRU/byte-budget shape as `DatSoundCache` (2026-07-24) / -`BoundedDatObjectCache`. Note sequencers retain references to loaded -animations — eviction must not invalidate an Animation a live sequencer -still holds (they keep the object alive; the cache only drops its own -reference). +**Fix:** `RetailAnimationLoader` now has one concurrent in-flight gate plus a +64 MiB / 512-entry LRU (startup-configurable through +`ResidencyBudgetOptions`). Duplicate requests coalesce, unrelated reads remain +parallel, null and oversize results cannot grow the cache, and eviction only +drops the cache reference. Live sequencers therefore keep their immutable +`Animation` object valid. Deterministic count, byte-pressure, oversize, LRU, +and concurrent-load tests cover the policy; the unified residency snapshot +reports decoded bytes, budget, and evictions. **Files:** `src/AcDream.Content/Vfx/RetailAnimationLoader.cs:19-53`. @@ -435,7 +437,7 @@ the ordered array on the publication object. ## #243 — ObjectMeshManager._boundsCache grows unbounded -**Status:** OPEN +**Status:** DONE — 2026-07-24, stale audit finding closed by Slice D3 **Severity:** LOW **Filed:** 2026-07-24 **Component:** render @@ -446,8 +448,12 @@ but the one unbounded container in a class where every other cache is deliberately budgeted, and another no-plateau curve for long-uptime sessions. -**Root cause / status:** 2026-07-24 audit review finding. Bound with the -existing entry-count LRU shape; plan Slice D's accounting covers it. +**Resolution:** The named field and insertion site do not exist in the current +tree: a repository-wide search finds no `_boundsCache` and +`ObjectMeshManager` has no bounds memoization owner. The review appears to +have described an older or different tree. Slice D deliberately does not add +a cache merely to bound it; current mesh/prepared/staging/arena owners are all +reported through the residency ledger. **Files:** `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs` (`_boundsCache`, insert site ~1757). diff --git a/docs/plans/2026-07-24-modern-runtime-slice-d-unified-residency.md b/docs/plans/2026-07-24-modern-runtime-slice-d-unified-residency.md index 4bc4af67..f0e73cf8 100644 --- a/docs/plans/2026-07-24-modern-runtime-slice-d-unified-residency.md +++ b/docs/plans/2026-07-24-modern-runtime-slice-d-unified-residency.md @@ -1,6 +1,6 @@ # Modern Runtime Slice D — Typed Asset Handles and Unified Residency -**Status:** active — D1 and D2 complete +**Status:** active — D1 through D3 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 @@ -227,6 +227,19 @@ residence. - Bring deferred-alpha retained scratch behind a bounded capacity owner. - Close stale #243 after proving no current bounds cache exists. +**Complete 2026-07-24.** `RetailAnimationLoader` now uses a concurrent +in-flight load gate feeding a byte-and-count bounded LRU. Cache eviction drops +only the cache reference, so active sequencers retain valid immutable +animations; duplicate DID requests coalesce while unrelated reads remain +parallel. The shared retail alpha path now partitions its 16 MiB startup budget +between the queue, object dispatcher, and particle renderer. Each physical +owner grows immediately when a frame requires it, reports exact retained +backing capacity, and shrinks only after repeated severe under-use. This bounds +one-frame density spikes without changing draw order, particle content, or +visible quality. Issue #240 is closed. Issue #243 is closed as a stale audit +finding: no `_boundsCache`, bounds memo, or cited insertion site exists in the +current source, so no replacement cache was invented. + ### D4 — Diagnostics and forced pressure - Publish aggregate and per-domain budgets/occupancy/fragmentation through the diff --git a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs index 429c5ef7..4a2c00ce 100644 --- a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs +++ b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs @@ -1,6 +1,7 @@ using AcDream.App.Audio; using AcDream.App.Physics; using AcDream.App.Rendering; +using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Vfx; using AcDream.App.Spells; using AcDream.Content; @@ -43,6 +44,7 @@ internal sealed record ContentEffectsAudioResult( internal sealed record ContentEffectsAudioDependencies( string DatDirectory, string PreparedAssetPath, + ResidencyBudgetOptions ResidencyBudgets, PhysicsDataCache PhysicsDataCache, bool DumpMotionEnabled, Spellbook SpellBook, @@ -84,7 +86,10 @@ internal interface IContentEffectsAudioCompositionFactory MagicCatalog LoadMagicCatalog(IDatReaderWriter dats); void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog); int GetSpellCount(MagicCatalog catalog); - IAnimationLoader CreateAnimationLoader(IDatReaderWriter dats); + IAnimationLoader CreateAnimationLoader( + IDatReaderWriter dats, + long maximumEstimatedBytes, + int maximumEntries); LiveEntityCollisionBuilder CreateCollisionBuilder( PhysicsDataCache physicsData, IDatReaderWriter dats, @@ -137,8 +142,14 @@ internal sealed class RetailContentEffectsAudioCompositionFactory public int GetSpellCount(MagicCatalog catalog) => catalog.SpellTable.Count; - public IAnimationLoader CreateAnimationLoader(IDatReaderWriter dats) => - new RetailAnimationLoader(dats); + public IAnimationLoader CreateAnimationLoader( + IDatReaderWriter dats, + long maximumEstimatedBytes, + int maximumEntries) => + new RetailAnimationLoader( + dats, + maximumEstimatedBytes, + maximumEntries); public LiveEntityCollisionBuilder CreateCollisionBuilder( PhysicsDataCache physicsData, @@ -302,7 +313,10 @@ internal sealed class ContentEffectsAudioCompositionPhase : $"spells: loaded {_factory.GetSpellCount(magic)} entries from portal.dat"); Fault(ContentEffectsAudioCompositionPoint.SpellMetadataInstalled); - IAnimationLoader animations = _factory.CreateAnimationLoader(dats); + IAnimationLoader animations = _factory.CreateAnimationLoader( + dats, + _dependencies.ResidencyBudgets.AnimationBytes, + _dependencies.ResidencyBudgets.AnimationEntries); _publication.PublishAnimationLoader(animations); Fault(ContentEffectsAudioCompositionPoint.AnimationLoaderPublished); diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 057bab5c..e7551405 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -5,6 +5,7 @@ using AcDream.App.Input; using AcDream.App.Interaction; using AcDream.App.Physics; using AcDream.App.Rendering; +using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Selection; using AcDream.App.Rendering.Sky; using AcDream.App.Rendering.Vfx; @@ -586,6 +587,9 @@ internal sealed class LivePresentationCompositionPhase { LivePresentationDependencies d = _dependencies; WorldRenderFoundation foundation = world.Foundation; + AlphaScratchBudgetProfile alphaScratchBudgets = + AlphaScratchBudgetProfile.Create( + d.Options.ResidencyBudgets.AlphaScratchBytes); var selectionScene = new RetailSelectionScene( new RetailSelectionGeometryCache(content.Dats, d.DatLock)); @@ -601,7 +605,8 @@ internal sealed class LivePresentationCompositionPhase d.ClassificationCache, d.TranslucencyFades, selectionScene, - d.RetailAlphaQueue), + d.RetailAlphaQueue, + alphaScratchBudgets.DispatcherBytes), static value => value.Dispose()); var selectionQuery = new WorldSelectionQuery( liveEntities, @@ -883,7 +888,8 @@ internal sealed class LivePresentationCompositionPhase foundation.TextureCache, content.Dats, foundation.MeshAdapter, - d.RetailAlphaQueue), + d.RetailAlphaQueue, + alphaScratchBudgets.ParticleBytes), static value => value.Dispose()); Fault(LivePresentationCompositionPoint.SkyAndParticlesCreated); @@ -966,6 +972,19 @@ internal sealed class LivePresentationCompositionPhase frameDiagnostics, bindings, landblockLoaded); + foundation.Residency.RegisterDomainSource( + new DelegateResidencyDomainSource( + ResidencyDomain.AlphaScratch, + () => new ResidencyDomainSnapshot( + ResidencyDomain.AlphaScratch, + EntryCount: 3, + OwnerCount: 3, + Charges: new ResidencyCharges( + ScratchBytes: checked( + d.RetailAlphaQueue.RetainedScratchBytes + + dispatcherLease.Resource.RetainedAlphaScratchBytes + + particleLease.Resource.RetainedAlphaScratchBytes)), + BudgetBytes: alphaScratchBudgets.TotalBytes))); _publication.PublishLivePresentation(result); liveRuntimeLease.Transfer(); diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs index f57798d7..7812e032 100644 --- a/src/AcDream.App/Composition/WorldRenderComposition.cs +++ b/src/AcDream.App/Composition/WorldRenderComposition.cs @@ -4,6 +4,8 @@ using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Residency; using AcDream.App.World; using AcDream.Content; +using AcDream.Content.Vfx; +using AcDream.Core.Physics; using AcDream.Core.Terrain; using AcDream.UI.Abstractions.Settings; using DatReaderWriter; @@ -116,7 +118,8 @@ internal interface IWorldRenderCompositionFactory ResidencyManager manager, WbMeshAdapter meshes, TextureCache textures, - IPreparedAssetSource preparedAssets); + IPreparedAssetSource preparedAssets, + IAnimationLoader animations); SamplerCache CreateSamplerCache(GL gl); void Release(IDisposable resource); } @@ -295,7 +298,8 @@ internal sealed class RetailWorldRenderCompositionFactory ResidencyManager manager, WbMeshAdapter meshes, TextureCache textures, - IPreparedAssetSource preparedAssets) + IPreparedAssetSource preparedAssets, + IAnimationLoader animations) { meshes.RegisterResidencySources(manager); textures.RegisterResidencySources(manager); @@ -308,6 +312,25 @@ internal sealed class RetailWorldRenderCompositionFactory Charges: new ResidencyCharges( MappedVirtualBytes: preparedAssets.MappedVirtualBytes)))); + if (animations is RetailAnimationLoader retailAnimations) + { + manager.RegisterDomainSource(new DelegateResidencyDomainSource( + ResidencyDomain.Animations, + () => + { + AnimationCacheDiagnostics diagnostics = + retailAnimations.Diagnostics; + return new ResidencyDomainSnapshot( + ResidencyDomain.Animations, + EntryCount: diagnostics.Count, + OwnerCount: 0, + Charges: new ResidencyCharges( + DecodedBytes: + diagnostics.EstimatedBytes), + BudgetBytes: diagnostics.BudgetBytes, + Evictions: diagnostics.Stats.Evictions); + })); + } } public SamplerCache CreateSamplerCache(GL gl) => new(gl); @@ -493,7 +516,8 @@ internal sealed class WorldRenderCompositionPhase residency, meshAdapter, textureCache, - content.PreparedAssets); + content.PreparedAssets, + content.AnimationLoader); scope.Complete(); _dependencies.Log( diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index c9cd54b8..1bd2627d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -281,7 +281,7 @@ public sealed class GameWindow : private AcDream.Content.Vfx.RetailPhysicsScriptLoader? _physicsScriptLoader; private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects; private AcDream.App.Rendering.ParticleRenderer? _particleRenderer; - private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue = new(); + private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue; // Remote-entity motion inference: tracks when each remote entity last // moved meaningfully. Used in TickAnimations to swap to Ready when // position has stalled for >StopIdleMs — retail observer pattern per @@ -564,6 +564,11 @@ public sealed class GameWindow : AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null) { _options = options ?? throw new System.ArgumentNullException(nameof(options)); + var alphaScratchBudgets = + AcDream.App.Rendering.Residency.AlphaScratchBudgetProfile.Create( + _options.ResidencyBudgets.AlphaScratchBytes); + _retailAlphaQueue = new AcDream.App.Rendering.RetailAlphaQueue( + alphaScratchBudgets.QueueBytes); WorldTime = _worldEnvironment.WorldTime; Weather = _worldEnvironment.Weather; _retainedInputCapture = new AcDream.App.Input.RetainedUiInputCaptureSlot(); @@ -1141,6 +1146,7 @@ public sealed class GameWindow : new ContentEffectsAudioDependencies( _datDir, _options.PreparedAssetPath, + _options.ResidencyBudgets, _physicsDataCache, _animationDiagnostics.DumpMotionEnabled, SpellBook, diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index 370cf8ea..303f1f7a 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.Numerics; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Wb; using AcDream.Content; using AcDream.Core.Meshing; @@ -181,6 +183,13 @@ public sealed unsafe class ParticleRenderer : IDisposable private DeferredParticleDraw[] _preparedAlpha = new DeferredParticleDraw[256]; private uint[] _preparedInstanceOffsets = new uint[256]; private int _preparedAlphaCount; + private readonly RetainedScratchCapacityPolicy _alphaScratchPolicy; + + internal long AlphaScratchBudgetBytes => _alphaScratchPolicy.BudgetBytes; + internal long RetainedAlphaScratchBytes => checked( + (long)_deferredAlpha.Capacity * Unsafe.SizeOf() + + (long)_preparedAlpha.Length * Unsafe.SizeOf() + + (long)_preparedInstanceOffsets.Length * sizeof(uint)); private sealed class AlphaDrawSource(ParticleRenderer owner) : IRetailAlphaDrawSource { @@ -201,7 +210,8 @@ public sealed unsafe class ParticleRenderer : IDisposable TextureCache? textures = null, IDatReaderWriter? dats = null, WbMeshAdapter? meshAdapter = null, - RetailAlphaQueue? alphaQueue = null) + RetailAlphaQueue? alphaQueue = null, + long? alphaScratchBudgetBytes = null) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); _textures = textures; @@ -210,6 +220,11 @@ public sealed unsafe class ParticleRenderer : IDisposable _particles = particles ?? throw new ArgumentNullException(nameof(particles)); _alphaQueue = alphaQueue; _alphaSource = new AlphaDrawSource(this); + long scratchBudget = alphaScratchBudgetBytes + ?? AlphaScratchBudgetProfile.Create( + ResidencyBudgetOptions.Default.AlphaScratchBytes).ParticleBytes; + _alphaScratchPolicy = + new RetainedScratchCapacityPolicy(scratchBudget); if (_meshAdapter is not null) { _meshReferences = new ParticleMeshReferenceTracker( @@ -732,8 +747,30 @@ public sealed unsafe class ParticleRenderer : IDisposable private void ResetDeferredAlpha() { + int observedCount = Math.Max( + _deferredAlpha.Count, + _preparedAlphaCount); _deferredAlpha.Clear(); _preparedAlphaCount = 0; + int currentCapacity = Math.Max( + _deferredAlpha.Capacity, + Math.Max( + _preparedAlpha.Length, + _preparedInstanceOffsets.Length)); + int bytesPerDraw = checked( + 2 * Unsafe.SizeOf() + sizeof(uint)); + int targetCapacity = _alphaScratchPolicy.ObserveAndSelectCapacity( + currentCapacity, + observedCount, + bytesPerDraw, + minimumCapacity: 256, + growthQuantum: 256); + if (targetCapacity >= currentCapacity) + return; + + _deferredAlpha.Capacity = targetCapacity; + Array.Resize(ref _preparedAlpha, targetCapacity); + Array.Resize(ref _preparedInstanceOffsets, targetCapacity); } private void PrepareBillboardPipeline(Matrix4x4 viewProjection) diff --git a/src/AcDream.App/Rendering/Residency/AssetResidencyTypes.cs b/src/AcDream.App/Rendering/Residency/AssetResidencyTypes.cs index 6d86f406..6db489f5 100644 --- a/src/AcDream.App/Rendering/Residency/AssetResidencyTypes.cs +++ b/src/AcDream.App/Rendering/Residency/AssetResidencyTypes.cs @@ -84,6 +84,7 @@ internal readonly record struct ResidencyCharges( long LogicalBytes = 0, long CpuPreparedBytes = 0, long DecodedBytes = 0, + long ScratchBytes = 0, long PinnedBytes = 0, long StagingBytes = 0, long GpuRequestedBytes = 0, @@ -97,6 +98,7 @@ internal readonly record struct ResidencyCharges( LogicalBytes + CpuPreparedBytes + DecodedBytes + + ScratchBytes + StagingBytes); public long PhysicalGpuBytes => checked( @@ -111,6 +113,7 @@ internal readonly record struct ResidencyCharges( ArgumentOutOfRangeException.ThrowIfNegative(LogicalBytes); ArgumentOutOfRangeException.ThrowIfNegative(CpuPreparedBytes); ArgumentOutOfRangeException.ThrowIfNegative(DecodedBytes); + ArgumentOutOfRangeException.ThrowIfNegative(ScratchBytes); ArgumentOutOfRangeException.ThrowIfNegative(PinnedBytes); ArgumentOutOfRangeException.ThrowIfNegative(StagingBytes); ArgumentOutOfRangeException.ThrowIfNegative(GpuRequestedBytes); @@ -126,6 +129,7 @@ internal readonly record struct ResidencyCharges( checked(left.LogicalBytes + right.LogicalBytes), checked(left.CpuPreparedBytes + right.CpuPreparedBytes), checked(left.DecodedBytes + right.DecodedBytes), + checked(left.ScratchBytes + right.ScratchBytes), checked(left.PinnedBytes + right.PinnedBytes), checked(left.StagingBytes + right.StagingBytes), checked(left.GpuRequestedBytes + right.GpuRequestedBytes), diff --git a/src/AcDream.App/Rendering/Residency/RetainedScratchCapacityPolicy.cs b/src/AcDream.App/Rendering/Residency/RetainedScratchCapacityPolicy.cs new file mode 100644 index 00000000..8c91c027 --- /dev/null +++ b/src/AcDream.App/Rendering/Residency/RetainedScratchCapacityPolicy.cs @@ -0,0 +1,105 @@ +namespace AcDream.App.Rendering.Residency; + +/// +/// Splits the one deferred-alpha scratch ceiling between the three physical +/// owners. The final share receives the division remainder so the aggregate +/// remains exact for every valid startup budget. +/// +internal readonly record struct AlphaScratchBudgetProfile( + long QueueBytes, + long DispatcherBytes, + long ParticleBytes) +{ + public long TotalBytes => checked( + QueueBytes + DispatcherBytes + ParticleBytes); + + public static AlphaScratchBudgetProfile Create(long totalBytes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(totalBytes); + long queue = totalBytes / 4; + long particles = totalBytes / 4; + long dispatcher = checked(totalBytes - queue - particles); + return new AlphaScratchBudgetProfile(queue, dispatcher, particles); + } +} + +/// +/// Capacity retention policy for render-thread scratch storage. Growth is +/// always immediate at the physical owner. This policy only recommends a +/// smaller warmed capacity after several completed low-demand observations, +/// preventing one dense alpha frame from pinning its high-water arrays for the +/// rest of the process without introducing an elapsed-time workaround. +/// +internal sealed class RetainedScratchCapacityPolicy( + long budgetBytes, + int lowDemandSamplesBeforeShrink = 3) +{ + private readonly long _budgetBytes = + budgetBytes > 0 + ? budgetBytes + : throw new ArgumentOutOfRangeException(nameof(budgetBytes)); + private readonly int _lowDemandSamplesBeforeShrink = + lowDemandSamplesBeforeShrink > 0 + ? lowDemandSamplesBeforeShrink + : throw new ArgumentOutOfRangeException( + nameof(lowDemandSamplesBeforeShrink)); + private int _lowDemandSamples; + + public long BudgetBytes => _budgetBytes; + + public int ObserveAndSelectCapacity( + int currentCapacity, + int requiredCapacity, + int bytesPerUnit, + int minimumCapacity, + int growthQuantum) + { + ArgumentOutOfRangeException.ThrowIfNegative(currentCapacity); + ArgumentOutOfRangeException.ThrowIfNegative(requiredCapacity); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytesPerUnit); + ArgumentOutOfRangeException.ThrowIfNegative(minimumCapacity); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(growthQuantum); + + if (requiredCapacity > currentCapacity) + { + _lowDemandSamples = 0; + return RoundUp(requiredCapacity, growthQuantum); + } + + int budgetCapacity = checked((int)Math.Min( + int.MaxValue, + Math.Max(minimumCapacity, _budgetBytes / bytesPerUnit))); + bool demandHasFallen = + currentCapacity > budgetCapacity + && requiredCapacity <= budgetCapacity + && (requiredCapacity == 0 + || (long)currentCapacity >= (long)requiredCapacity * 4); + if (!demandHasFallen) + { + _lowDemandSamples = 0; + return currentCapacity; + } + + _lowDemandSamples++; + if (_lowDemandSamples < _lowDemandSamplesBeforeShrink) + return currentCapacity; + + _lowDemandSamples = 0; + long warmedDemand = Math.Max( + minimumCapacity, + Math.Min(int.MaxValue, (long)requiredCapacity * 2)); + int target = RoundUp( + checked((int)Math.Min(warmedDemand, budgetCapacity)), + growthQuantum); + target = Math.Max(requiredCapacity, Math.Min(target, budgetCapacity)); + return Math.Min(currentCapacity, target); + } + + private static int RoundUp(int value, int quantum) + { + if (value == 0) + return 0; + long rounded = ((long)value + quantum - 1) / quantum * quantum; + return checked((int)Math.Min(int.MaxValue, rounded)); + } +} diff --git a/src/AcDream.App/Rendering/RetailAlphaQueue.cs b/src/AcDream.App/Rendering/RetailAlphaQueue.cs index b4ff1e9e..9fea6157 100644 --- a/src/AcDream.App/Rendering/RetailAlphaQueue.cs +++ b/src/AcDream.App/Rendering/RetailAlphaQueue.cs @@ -2,7 +2,9 @@ using System; using System.Collections.Generic; using System.Numerics; using System.Runtime.ExceptionServices; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using AcDream.App.Rendering.Residency; namespace AcDream.App.Rendering; @@ -68,15 +70,42 @@ internal interface IWorldSceneAlphaFrame internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame { + private const int MinimumSubmissionCapacity = 256; + private const int SubmissionGrowthQuantum = 256; + private const int MinimumSourceCapacity = 4; private readonly List _submissions = new(256); private readonly List _sources = new(4); private int[] _tokenScratch = new int[256]; private int[] _sourceDrawOffsets = new int[4]; private RetailAlphaSubmission[] _sortScratch = new RetailAlphaSubmission[256]; private readonly int[] _radixOffsets = new int[256]; + private readonly RetainedScratchCapacityPolicy _scratchPolicy; + private readonly long _scratchBudgetBytes; + + internal RetailAlphaQueue(long? scratchBudgetBytes = null) + { + long budget = scratchBudgetBytes + ?? AlphaScratchBudgetProfile.Create( + ResidencyBudgetOptions.Default.AlphaScratchBytes).QueueBytes; + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(budget); + _scratchBudgetBytes = budget; + _scratchPolicy = new RetainedScratchCapacityPolicy(Math.Max( + 1, + budget - (long)_radixOffsets.Length * sizeof(int))); + } public bool IsCollecting { get; private set; } internal int PendingCount => _submissions.Count; + internal long ScratchBudgetBytes => _scratchBudgetBytes; + internal long RetainedScratchBytes => checked( + (long)_submissions.Capacity + * Unsafe.SizeOf() + + (long)_sortScratch.Length + * Unsafe.SizeOf() + + (long)_tokenScratch.Length * sizeof(int) + + (long)_sources.Capacity * IntPtr.Size + + (long)_sourceDrawOffsets.Length * sizeof(int) + + (long)_radixOffsets.Length * sizeof(int)); public void BeginFrame() { @@ -171,6 +200,8 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame } finally { + int submissionCount = _submissions.Count; + int sourceCount = _sources.Count; for (int i = 0; i < _sources.Count; i++) { try @@ -184,6 +215,7 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame } _sources.Clear(); _submissions.Clear(); + ApplyScratchRetention(submissionCount, sourceCount); } if (drawFailure is not null) @@ -246,9 +278,12 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame } finally { + int submissionCount = _submissions.Count; + int sourceCount = _sources.Count; _sources.Clear(); _submissions.Clear(); IsCollecting = false; + ApplyScratchRetention(submissionCount, sourceCount); } if (failures is { Count: > 0 }) @@ -339,4 +374,40 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame return i; throw new InvalidOperationException("Retail alpha source was not registered for this frame."); } + + private void ApplyScratchRetention( + int observedSubmissionCount, + int observedSourceCount) + { + int currentCapacity = Math.Max( + _submissions.Capacity, + Math.Max(_tokenScratch.Length, _sortScratch.Length)); + int bytesPerSubmission = + checked( + 2 * Unsafe.SizeOf() + + 2 * sizeof(int) + + IntPtr.Size); + int targetCapacity = _scratchPolicy.ObserveAndSelectCapacity( + currentCapacity, + observedSubmissionCount, + bytesPerSubmission, + MinimumSubmissionCapacity, + SubmissionGrowthQuantum); + if (targetCapacity < currentCapacity) + { + _submissions.Capacity = targetCapacity; + Array.Resize(ref _tokenScratch, targetCapacity); + Array.Resize(ref _sortScratch, targetCapacity); + + int sourceTarget = Math.Max( + MinimumSourceCapacity, + observedSourceCount == 0 + ? MinimumSourceCapacity + : checked(observedSourceCount * 2)); + sourceTarget = Math.Min(sourceTarget, _sources.Capacity); + _sources.Capacity = sourceTarget; + if (_sourceDrawOffsets.Length > sourceTarget) + Array.Resize(ref _sourceDrawOffsets, sourceTarget); + } + } } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index f35597d9..a6f0b914 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.Numerics; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using AcDream.App.Rendering.Residency; using AcDream.Core.Lighting; using AcDream.Core.Meshing; using AcDream.Core.Rendering; @@ -91,6 +93,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private readonly IRetailSelectionLightingSource? _selectionLighting; private readonly RetailAlphaQueue? _alphaQueue; private readonly AlphaDrawSource _alphaSource; + private readonly RetainedScratchCapacityPolicy _alphaScratchPolicy; + private int _scratchPeakUnits; private readonly BindlessSupport _bindless; @@ -596,7 +600,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable => owner.DrawPreparedAlphaBatch(firstPreparedDraw, drawCount); public void ResetAlphaSubmissions() - => owner._deferredAlpha.Clear(); + => owner.ResetDeferredAlphaSubmissions(); } // Per-frame scratch — reused across frames to avoid per-frame allocation. @@ -606,6 +610,25 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private readonly List _deferredAlpha = new(128); private TranslucencyKind[] _deferredAlphaKinds = new TranslucencyKind[128]; private Matrix4x4 _deferredAlphaViewProjection; + + internal long AlphaScratchBudgetBytes => _alphaScratchPolicy.BudgetBytes; + internal long RetainedAlphaScratchBytes => checked( + (long)_instanceData.Length * sizeof(float) + + (long)_clipSlotData.Length * sizeof(uint) + + (long)_lightSetData.Length * sizeof(int) + + (long)_indoorData.Length * sizeof(uint) + + (long)_alphaData.Length * sizeof(float) + + (long)_selectionLightingData.Length * Unsafe.SizeOf() + + (long)_batchData.Length * Unsafe.SizeOf() + + (long)_indirectCommands.Length + * Unsafe.SizeOf() + + (long)_drawCullModes.Length * Unsafe.SizeOf() + + (long)_batchPublicScratch.Length + * Unsafe.SizeOf() + + (long)_deferredAlphaKinds.Length + * Unsafe.SizeOf() + + (long)_deferredAlpha.Capacity + * Unsafe.SizeOf()); // A.5 T26 follow-up (Bug B): WalkEntities populates this scratch list // instead of allocating a fresh List<(WorldEntity, int)> per frame. At // ~10K entities × ~3 mesh refs = ~30K tuples × 16 bytes = ~480 KB / frame @@ -729,7 +752,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable EntityClassificationCache classificationCache, AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades, IRetailSelectionRenderSink? selectionSink = null, - RetailAlphaQueue? alphaQueue = null) + RetailAlphaQueue? alphaQueue = null, + long? alphaScratchBudgetBytes = null) { ArgumentNullException.ThrowIfNull(gl); ArgumentNullException.ThrowIfNull(shader); @@ -750,6 +774,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _selectionLighting = selectionSink as IRetailSelectionLightingSource; _alphaQueue = alphaQueue; _alphaSource = new AlphaDrawSource(this); + long scratchBudget = alphaScratchBudgetBytes + ?? AlphaScratchBudgetProfile.Create( + ResidencyBudgetOptions.Default.AlphaScratchBytes).DispatcherBytes; + _alphaScratchPolicy = + new RetainedScratchCapacityPolicy(scratchBudget); _bindless = bindless ?? throw new ArgumentNullException(nameof(bindless)); } @@ -766,6 +795,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (_groupFrame == long.MaxValue) throw new InvalidOperationException("Instance-group frame identity was exhausted."); + ApplyScratchRetention(_scratchPeakUnits); + _scratchPeakUnits = 0; _groupFrame++; PruneInstanceGroupsUnusedBeforeFrame( _groups, @@ -1965,6 +1996,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // fill via BuildIndirectArrays ────────────────────────────────── int immediateTransparentCount = deferTransparent ? 0 : _translucentDraws.Count; int totalDraws = _opaqueDraws.Count + immediateTransparentCount; + TrackScratchDemand(Math.Max(totalInstances, totalDraws)); if (_batchData.Length < totalDraws) _batchData = new BatchData[totalDraws + 64]; if (_indirectCommands.Length < totalDraws) @@ -2493,6 +2525,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private void EnsureDeferredAlphaCapacity(int count) { + TrackScratchDemand(count); int neededMatrixFloats = count * 16; if (_instanceData.Length < neededMatrixFloats) _instanceData = new float[neededMatrixFloats + 256 * 16]; @@ -2516,6 +2549,63 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _deferredAlphaKinds = new TranslucencyKind[count + 64]; } + private void ResetDeferredAlphaSubmissions() + { + _deferredAlpha.Clear(); + } + + private void TrackScratchDemand(int units) + { + if (units > _scratchPeakUnits) + _scratchPeakUnits = units; + } + + private void ApplyScratchRetention(int observedUnits) + { + int currentCapacity = Math.Max( + _instanceData.Length / 16, + Math.Max( + _lightSetData.Length / LightManager.MaxLightsPerObject, + Math.Max( + _deferredAlpha.Capacity, + Math.Max(_batchData.Length, _indirectCommands.Length)))); + int bytesPerUnit = checked( + 16 * sizeof(float) + + sizeof(uint) + + LightManager.MaxLightsPerObject * sizeof(int) + + sizeof(uint) + + sizeof(float) + + Unsafe.SizeOf() + + Unsafe.SizeOf() + + Unsafe.SizeOf() + + Unsafe.SizeOf() + + Unsafe.SizeOf() + + Unsafe.SizeOf() + + Unsafe.SizeOf()); + int targetCapacity = _alphaScratchPolicy.ObserveAndSelectCapacity( + currentCapacity, + observedUnits, + bytesPerUnit, + minimumCapacity: 256, + growthQuantum: 256); + if (targetCapacity >= currentCapacity) + return; + + _instanceData = new float[checked(targetCapacity * 16)]; + _clipSlotData = new uint[targetCapacity]; + _lightSetData = new int[ + checked(targetCapacity * LightManager.MaxLightsPerObject)]; + _indoorData = new uint[targetCapacity]; + _alphaData = new float[targetCapacity]; + _selectionLightingData = new Vector2[targetCapacity]; + _batchData = new BatchData[targetCapacity]; + _indirectCommands = new DrawElementsIndirectCommand[targetCapacity]; + _drawCullModes = new CullMode[targetCapacity]; + _batchPublicScratch = new BatchDataPublic[targetCapacity]; + _deferredAlphaKinds = new TranslucencyKind[targetCapacity]; + _deferredAlpha.Capacity = targetCapacity; + } + private void UploadDeferredAlphaBuffers(int count) { fixed (float* p = _instanceData) diff --git a/src/AcDream.Content/Vfx/RetailAnimationLoader.cs b/src/AcDream.Content/Vfx/RetailAnimationLoader.cs index f214ef64..c8da02d0 100644 --- a/src/AcDream.Content/Vfx/RetailAnimationLoader.cs +++ b/src/AcDream.Content/Vfx/RetailAnimationLoader.cs @@ -18,26 +18,95 @@ namespace AcDream.Content.Vfx; /// public sealed class RetailAnimationLoader : IAnimationLoader { + public const long DefaultMaximumEstimatedBytes = 64L * 1024L * 1024L; + public const int DefaultMaximumEntries = 512; + private const long NegativeEntryEstimate = 128; + + private readonly record struct LoadResult( + Animation? Animation, + long EstimatedBytes); + + private sealed record CacheEntry( + uint Id, + Animation? Animation, + long EstimatedBytes); + private readonly Func _readRaw; private readonly DatDatabase? _database; - private readonly ConcurrentDictionary> _cache = new(); + private readonly ConcurrentDictionary> _inflight = new(); + private readonly object _gate = new(); + private readonly Dictionary> _entries = []; + private readonly LinkedList _leastRecentlyUsed = []; + private readonly long _maximumEstimatedBytes; + private readonly int _maximumEntries; + private long _estimatedBytes; + private long _hits; + private long _misses; + private long _evictions; public RetailAnimationLoader(IDatReaderWriter dats) + : this( + dats, + DefaultMaximumEstimatedBytes, + DefaultMaximumEntries) + { + } + + public RetailAnimationLoader( + IDatReaderWriter dats, + long maximumEstimatedBytes, + int maximumEntries) { ArgumentNullException.ThrowIfNull(dats); + ValidateBudgets(maximumEstimatedBytes, maximumEntries); _database = dats.Portal.Db; _readRaw = id => dats.Portal.TryGetFileBytes(id, out byte[]? bytes) ? bytes : null; + _maximumEstimatedBytes = maximumEstimatedBytes; + _maximumEntries = maximumEntries; } public RetailAnimationLoader(IDatDatabase portal) + : this( + portal, + DefaultMaximumEstimatedBytes, + DefaultMaximumEntries) + { + } + + public RetailAnimationLoader( + IDatDatabase portal, + long maximumEstimatedBytes, + int maximumEntries) { ArgumentNullException.ThrowIfNull(portal); + ValidateBudgets(maximumEstimatedBytes, maximumEntries); _database = portal.Db; _readRaw = id => portal.TryGetFileBytes(id, out byte[]? bytes) ? bytes : null; + _maximumEstimatedBytes = maximumEstimatedBytes; + _maximumEntries = maximumEntries; + } + + public AnimationCacheDiagnostics Diagnostics + { + get + { + lock (_gate) + { + return new AnimationCacheDiagnostics( + _entries.Count, + _estimatedBytes, + _maximumEstimatedBytes, + _maximumEntries, + new CacheStats( + Interlocked.Read(ref _hits), + Interlocked.Read(ref _misses), + Interlocked.Read(ref _evictions))); + } + } } public Animation? LoadAnimation(uint id) @@ -45,24 +114,156 @@ public sealed class RetailAnimationLoader : IAnimationLoader if (!IsAnimationDid(id)) return null; - return _cache.GetOrAdd( + if (TryGet(id, out Animation? cached)) + return cached; + + Lazy candidate = new( + () => LoadUncached(id), + LazyThreadSafetyMode.ExecutionAndPublication); + Lazy shared = _inflight.GetOrAdd( id, - key => new Lazy( - () => LoadUncached(key), - LazyThreadSafetyMode.ExecutionAndPublication)).Value; + candidate); + try + { + LoadResult loaded = shared.Value; + return Admit(id, loaded); + } + finally + { + _inflight.TryRemove( + new KeyValuePair>(id, shared)); + } } - private Animation? LoadUncached(uint id) + private bool TryGet(uint id, out Animation? animation) + { + lock (_gate) + { + if (!_entries.TryGetValue( + id, + out LinkedListNode? node)) + { + Interlocked.Increment(ref _misses); + animation = null; + return false; + } + + Interlocked.Increment(ref _hits); + Touch(node); + animation = node.Value.Animation; + return true; + } + } + + private LoadResult LoadUncached(uint id) { byte[]? bytes = _readRaw(id); if (bytes is null) - return null; + return new LoadResult(null, NegativeEntryEstimate); Animation animation = Parse(bytes, _database); if (animation.Id != id) throw new InvalidDataException( $"Animation entry 0x{id:X8} contained id 0x{animation.Id:X8}."); - return animation; + return new LoadResult( + animation, + EstimateRetainedBytes(animation, bytes.LongLength)); + } + + private Animation? Admit(uint id, LoadResult loaded) + { + long estimatedBytes = Math.Max(1, loaded.EstimatedBytes); + lock (_gate) + { + if (_entries.TryGetValue( + id, + out LinkedListNode? existing)) + { + Touch(existing); + return existing.Value.Animation; + } + + if (estimatedBytes > _maximumEstimatedBytes) + return loaded.Animation; + + while (_entries.Count >= _maximumEntries + || _estimatedBytes + > _maximumEstimatedBytes - estimatedBytes) + { + EvictLeastRecentlyUsed(); + } + + var entry = new CacheEntry( + id, + loaded.Animation, + estimatedBytes); + LinkedListNode node = + _leastRecentlyUsed.AddLast(entry); + _entries.Add(id, node); + _estimatedBytes = checked( + _estimatedBytes + estimatedBytes); + return loaded.Animation; + } + } + + private void Touch(LinkedListNode node) + { + if (ReferenceEquals(node, _leastRecentlyUsed.Last)) + return; + _leastRecentlyUsed.Remove(node); + _leastRecentlyUsed.AddLast(node); + } + + private void EvictLeastRecentlyUsed() + { + LinkedListNode? node = _leastRecentlyUsed.First; + if (node is null) + { + throw new InvalidOperationException( + "Animation cache accounting is inconsistent."); + } + + _leastRecentlyUsed.RemoveFirst(); + _entries.Remove(node.Value.Id); + _estimatedBytes = checked( + _estimatedBytes - node.Value.EstimatedBytes); + Interlocked.Increment(ref _evictions); + } + + internal static long EstimateRetainedBytes( + Animation animation, + long rawBytes) + { + ArgumentNullException.ThrowIfNull(animation); + ArgumentOutOfRangeException.ThrowIfNegative(rawBytes); + + // Generated DAT graph nodes do not expose managed-size metadata. + // Charge their known list/object shape conservatively; the independent + // entry ceiling remains the hard bound if a future hook subtype holds + // an unmeasured child graph. + long estimate = 512; + estimate = checked( + estimate + animation.PosFrames.Count * 64L); + for (int i = 0; i < animation.PartFrames.Count; i++) + { + AnimationFrame frame = animation.PartFrames[i]; + estimate = checked( + estimate + + 96L + + frame.Frames.Count * 64L + + frame.Hooks.Count * 192L); + } + return Math.Max(rawBytes, estimate); + } + + private static void ValidateBudgets( + long maximumEstimatedBytes, + int maximumEntries) + { + ArgumentOutOfRangeException.ThrowIfLessThan( + maximumEstimatedBytes, + 1); + ArgumentOutOfRangeException.ThrowIfLessThan(maximumEntries, 1); } public static Animation Parse( @@ -106,3 +307,10 @@ public sealed class RetailAnimationLoader : IAnimationLoader // AC data IDs reserve only the high byte for DBObj type. (did & 0xFF000000u) == 0x03000000u; } + +public readonly record struct AnimationCacheDiagnostics( + int Count, + long EstimatedBytes, + long BudgetBytes, + int EntryLimit, + CacheStats Stats); diff --git a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs index ab324782..7fe2adf6 100644 --- a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs @@ -4,6 +4,7 @@ using AcDream.App.Audio; using AcDream.App.Composition; using AcDream.App.Physics; using AcDream.App.Rendering; +using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Vfx; using AcDream.App.Spells; using AcDream.Content; @@ -253,6 +254,7 @@ public sealed class ContentEffectsAudioCompositionTests Dependencies = new ContentEffectsAudioDependencies( "test-dat", "test.pak", + ResidencyBudgetOptions.Default, new PhysicsDataCache(), false, new Spellbook(), @@ -386,7 +388,19 @@ public sealed class ContentEffectsAudioCompositionTests public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => _magic; public void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog) { } public int GetSpellCount(MagicCatalog catalog) => 0; - public IAnimationLoader CreateAnimationLoader(IDatReaderWriter dats) => _animations; + public IAnimationLoader CreateAnimationLoader( + IDatReaderWriter dats, + long maximumEstimatedBytes, + int maximumEntries) + { + Assert.Equal( + ResidencyBudgetOptions.Default.AnimationBytes, + maximumEstimatedBytes); + Assert.Equal( + ResidencyBudgetOptions.Default.AnimationEntries, + maximumEntries); + return _animations; + } public LiveEntityCollisionBuilder CreateCollisionBuilder( PhysicsDataCache physicsData, diff --git a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs index aee6b6e4..5f7cf6a1 100644 --- a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs @@ -6,6 +6,7 @@ using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Residency; using AcDream.App.World; using AcDream.Content; +using AcDream.Core.Physics; using AcDream.Core.Terrain; using AcDream.UI.Abstractions.Settings; using DatReaderWriter.DBObjs; @@ -306,7 +307,8 @@ public sealed class WorldRenderCompositionTests ResidencyManager manager, WbMeshAdapter meshes, TextureCache textures, - IPreparedAssetSource preparedAssets) + IPreparedAssetSource preparedAssets, + IAnimationLoader animations) { RegisteredResidency = manager; } diff --git a/tests/AcDream.App.Tests/Rendering/Residency/ResidencyManagerTests.cs b/tests/AcDream.App.Tests/Rendering/Residency/ResidencyManagerTests.cs index 77fe3c03..d6843a25 100644 --- a/tests/AcDream.App.Tests/Rendering/Residency/ResidencyManagerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Residency/ResidencyManagerTests.cs @@ -7,6 +7,20 @@ public sealed class ResidencyManagerTests private sealed class MeshAsset; private sealed class TextureAsset; + [Fact] + public void ScratchBytesAreCommittedCpuResidence() + { + var charges = new ResidencyCharges( + LogicalBytes: 1, + CpuPreparedBytes: 2, + DecodedBytes: 3, + ScratchBytes: 4, + StagingBytes: 5); + + Assert.Equal(15, charges.CommittedCpuBytes); + charges.Validate(); + } + [Fact] public void HandlesAndOwnersReserveZeroAsInvalid() { diff --git a/tests/AcDream.App.Tests/Rendering/Residency/RetainedScratchCapacityPolicyTests.cs b/tests/AcDream.App.Tests/Rendering/Residency/RetainedScratchCapacityPolicyTests.cs new file mode 100644 index 00000000..a01d6ac4 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Residency/RetainedScratchCapacityPolicyTests.cs @@ -0,0 +1,86 @@ +using AcDream.App.Rendering.Residency; + +namespace AcDream.App.Tests.Rendering.Residency; + +public sealed class RetainedScratchCapacityPolicyTests +{ + [Fact] + public void AlphaBudgetProfilePartitionsTheAggregateExactly() + { + AlphaScratchBudgetProfile profile = + AlphaScratchBudgetProfile.Create(16 * 1024 * 1024); + + Assert.Equal(4 * 1024 * 1024, profile.QueueBytes); + Assert.Equal(8 * 1024 * 1024, profile.DispatcherBytes); + Assert.Equal(4 * 1024 * 1024, profile.ParticleBytes); + Assert.Equal(16 * 1024 * 1024, profile.TotalBytes); + } + + [Fact] + public void SpikeRetainsWarmCapacityUntilDemandFallsThenConverges() + { + var policy = new RetainedScratchCapacityPolicy( + budgetBytes: 1024, + lowDemandSamplesBeforeShrink: 3); + + Assert.Equal( + 4096, + policy.ObserveAndSelectCapacity( + currentCapacity: 4096, + requiredCapacity: 4096, + bytesPerUnit: 4, + minimumCapacity: 64, + growthQuantum: 64)); + Assert.Equal(4096, ObserveLowDemand(policy)); + Assert.Equal(4096, ObserveLowDemand(policy)); + + int converged = ObserveLowDemand(policy); + + Assert.InRange(converged, 64, 256); + Assert.True((long)converged * 4 <= 1024); + } + + [Fact] + public void SustainedLegitimateDemandIsNeverTrimmedBelowRequirement() + { + var policy = new RetainedScratchCapacityPolicy( + budgetBytes: 1024, + lowDemandSamplesBeforeShrink: 2); + + for (int i = 0; i < 10; i++) + { + Assert.Equal( + 4096, + policy.ObserveAndSelectCapacity( + currentCapacity: 4096, + requiredCapacity: 4096, + bytesPerUnit: 4, + minimumCapacity: 64, + growthQuantum: 64)); + } + } + + [Fact] + public void RequiredGrowthIsImmediateAndQuantumRounded() + { + var policy = new RetainedScratchCapacityPolicy(1024); + + Assert.Equal( + 320, + policy.ObserveAndSelectCapacity( + currentCapacity: 64, + requiredCapacity: 257, + bytesPerUnit: 4, + minimumCapacity: 64, + growthQuantum: 64)); + } + + private static int ObserveLowDemand( + RetainedScratchCapacityPolicy policy) => + policy.ObserveAndSelectCapacity( + currentCapacity: 4096, + requiredCapacity: 32, + bytesPerUnit: 4, + minimumCapacity: 64, + growthQuantum: 64); +} diff --git a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs index a6338406..2141e611 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs @@ -157,6 +157,31 @@ public sealed class RetailAlphaQueueTests Assert.Equal(expected, source.PreparedTokens); } + [Fact] + public void RetainedScratchConvergesAfterAOneScopeSpike() + { + const int budgetBytes = 128 * 1024; + var log = new List(); + var source = new RecordingSource("alpha", log); + var queue = new RetailAlphaQueue(budgetBytes); + + queue.BeginFrame(); + for (int i = 0; i < 8_192; i++) + queue.Submit(source, i, i); + queue.EndFrame(); + Assert.True(queue.RetainedScratchBytes > budgetBytes); + + for (int i = 0; i < 3; i++) + { + queue.BeginFrame(); + queue.EndFrame(); + } + + Assert.True(queue.RetainedScratchBytes <= budgetBytes); + Assert.False(queue.IsCollecting); + Assert.Equal(0, queue.PendingCount); + } + [Fact] public void Flush_EqualDistanceOrderStartsFreshInEachFrameScope() { diff --git a/tests/AcDream.Content.Tests/Vfx/RetailDatLoaderTests.cs b/tests/AcDream.Content.Tests/Vfx/RetailDatLoaderTests.cs index 7d8425fa..76426298 100644 --- a/tests/AcDream.Content.Tests/Vfx/RetailDatLoaderTests.cs +++ b/tests/AcDream.Content.Tests/Vfx/RetailDatLoaderTests.cs @@ -202,6 +202,81 @@ public sealed class RetailDatLoaderTests Assert.Throws(() => animations.LoadAnimation(wrongAnimationKey)); } + [Fact] + public void AnimationCache_CountPressureEvictsLruWithoutInvalidatingLiveReferences() + { + const uint firstDid = 0x03010020u; + const uint secondDid = 0x03010021u; + const uint thirdDid = 0x03010022u; + var portal = new RawDatabase(); + portal.Add(firstDid, AnimationBytes(firstDid)); + portal.Add(secondDid, AnimationBytes(secondDid)); + portal.Add(thirdDid, AnimationBytes(thirdDid)); + var loader = new RetailAnimationLoader( + portal, + maximumEstimatedBytes: long.MaxValue, + maximumEntries: 2); + + DatAnimation first = Assert.IsType( + loader.LoadAnimation(firstDid)); + _ = Assert.IsType(loader.LoadAnimation(secondDid)); + _ = Assert.IsType(loader.LoadAnimation(thirdDid)); + + AnimationCacheDiagnostics pressured = loader.Diagnostics; + Assert.Equal(2, pressured.Count); + Assert.Equal(1, pressured.Stats.Evictions); + Assert.Equal(firstDid, first.Id); + + DatAnimation reloaded = Assert.IsType( + loader.LoadAnimation(firstDid)); + Assert.NotSame(first, reloaded); + Assert.Equal(4, portal.TotalReads); + Assert.Equal(2, loader.Diagnostics.Stats.Evictions); + } + + [Fact] + public void AnimationCache_OversizeEntryIsServedButNeverRetained() + { + const uint animationDid = 0x03010023u; + var portal = new RawDatabase(); + portal.Add(animationDid, AnimationBytes(animationDid)); + var loader = new RetailAnimationLoader( + portal, + maximumEstimatedBytes: 1, + maximumEntries: 2); + + Assert.NotNull(loader.LoadAnimation(animationDid)); + Assert.NotNull(loader.LoadAnimation(animationDid)); + + Assert.Equal(0, loader.Diagnostics.Count); + Assert.Equal(0, loader.Diagnostics.EstimatedBytes); + Assert.Equal(2, portal.TotalReads); + } + + [Fact] + public async Task AnimationCache_CoalescesSameDidAndAllowsUnrelatedReadsInParallel() + { + const uint firstDid = 0x03010024u; + const uint secondDid = 0x03010025u; + var portal = new RawDatabase { ReadDelayMilliseconds = 40 }; + portal.Add(firstDid, AnimationBytes(firstDid)); + portal.Add(secondDid, AnimationBytes(secondDid)); + var loader = new RetailAnimationLoader(portal); + + DatAnimation?[] firstPair = await Task.WhenAll( + Task.Run(() => loader.LoadAnimation(firstDid)), + Task.Run(() => loader.LoadAnimation(firstDid))); + Assert.Same(firstPair[0], firstPair[1]); + Assert.Equal(1, portal.TotalReads); + + await Task.WhenAll( + Task.Run(() => loader.LoadAnimation(secondDid)), + Task.Run(() => loader.LoadAnimation(0x03010026u))); + + Assert.True(portal.MaxConcurrentReads >= 2); + Assert.Equal(3, portal.TotalReads); + } + [Fact] public async Task PhysicsScriptLoader_AllowsConcurrentFirstReads() { @@ -224,6 +299,16 @@ public sealed class RetailDatLoaderTests Assert.Equal(2, portal.TotalReads); } + private static byte[] AnimationBytes(uint id) + { + byte[] bytes = + ProjectileVfxDatFixtures.OrdinaryAnimation.ToArray(); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian( + bytes, + id); + return bytes; + } + [Fact] public async Task PhysicsScriptLoader_CoalescesConcurrentReadsForTheSameDid() {