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 new file mode 100644 index 00000000..d4029ea3 --- /dev/null +++ b/docs/plans/2026-07-24-modern-runtime-slice-d-unified-residency.md @@ -0,0 +1,245 @@ +# Modern Runtime Slice D — Typed Asset Handles and Unified Residency + +**Status:** active +**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 + +## 1. Outcome + +Slice D makes runtime memory finite, attributable, and generation-safe without +replacing the specialized caches that already work. + +One `ResidencyManager` owns policy, logical asset identity, generations, +leases, budgets, and the aggregate diagnostic snapshot. Existing renderer and +content owners continue to own their physical storage: + +- `ObjectMeshManager` owns object render data, mesh ranges, texture atlases, + prepared CPU mesh entries, and staged uploads. +- `GlobalMeshBuffer` owns vertex/index arenas and fence-delayed range reuse. +- `CompositeTextureArrayCache` owns pooled composite-array layers and arrays. +- `StandaloneBindlessTextureCache` owns standalone particle arrays. +- `GpuFrameFlightController` and `GpuRetirementLedger` own fence-delayed + physical release. +- `RetailAnimationLoader` owns parsed animation residence. +- Deferred-alpha producers own their reusable CPU scratch arrays. + +The manager never stores or deletes an OpenGL name. It observes immutable +facts, makes bounded policy decisions during the update/resource-maintenance +phase, and issues logical trim requests. The physical owner executes any GL +release during its existing render-thread maintenance phase. + +## 2. Current-owner inventory + +| Domain | Existing policy | Physical owner | Slice D action | +|---|---|---|---| +| Object render data | 1 GiB estimated GPU / 50 unowned objects, per-frame bounded reclamation | `ObjectMeshManager` | Preserve; source limits from typed budgets; report logical/non-arena/arena/retiring bytes separately | +| Prepared mesh CPU cache | 100 entries / 128 MiB LRU | `CpuMeshUploadCache` | Preserve; source limits from typed budgets; report hits, misses, evictions, count, bytes | +| Mesh staging | 256 claims / 128 MiB, generation checked | `MeshUploadStagingQueue` | Preserve; source limits from typed budgets; report queued and claimed bytes separately | +| Global mesh arenas | 384 MiB vertex, 128 MiB index, 896 MiB maximum physical overlap; shrink hysteresis | `GlobalMeshBuffer` | Preserve; expose live, free, largest-free, staging, retired, and fragmentation facts | +| Composite texture arrays | 64 MiB unowned / 128 MiB physical, throttled logical eviction and fence release | `CompositeTextureArrayCache` | Preserve; source limits from typed budgets; report resident/unowned/retiring/fragmentation | +| Standalone particle arrays | 32 MiB / 256 unowned, one eviction per frame | `StandaloneBindlessTextureCache` | Preserve; source limits from typed budgets; report owned/unowned/retiring bytes | +| Prepared package | one immutable memory map; payload arrays are charged to CPU cache/staging | `PakPreparedAssetSource` | Report mapped virtual bytes separately; do not mislabel the 27.5 GiB address map as committed residence | +| Parsed animations | unbounded dictionary | `RetailAnimationLoader` | Add byte-and-count bounded concurrent LRU; a live sequencer keeps its own reference after cache eviction | +| Decoded audio waves | 32 MiB LRU | `DatSoundCache` | Preserve and report; sound-table metadata is finite DAT identity metadata | +| Deferred-alpha scratch | grow-only lists/arrays at three submission seams | producer owners | Add capacity-budgeted retention with immediate correctness-preserving growth and policy-driven shrink | +| Mesh bounds memo | not present in current source | none | Close stale issue #243; do not add a replacement | + +The process-wide `GpuMemoryTracker` remains an independent physical-allocation +cross-check. It is not a policy owner because it intentionally covers shaders, +framebuffers, UI textures, and other allocations outside Slice D's cache set. + +## 3. Typed identity + +```csharp +readonly record struct AssetHandle(uint Index, ushort Generation); +readonly record struct OwnerToken(uint Index, ushort Generation); +readonly record struct AssetLease( + AssetHandle Handle, + OwnerToken Owner); +``` + +Rules: + +1. Index zero and generation zero are invalid. +2. A slot generation increments before an index is reused. +3. A stale handle cannot touch, transition, lease, release, or retire its + replacement generation. +4. Owner tokens are allocated and retired independently from assets. Retiring + an owner releases exactly that owner's leases. +5. Repeated acquisition of the same `(asset, owner)` is idempotent. +6. Callers never derive a handle, owner, or generation through an integer cast. +7. Physical cache keys remain domain-specific (`ulong` mesh id, + `CompositeTextureKey`, surface DID). An adapter maps those keys to typed + handles; the manager does not replace their key or storage types. + +## 4. State machine + +The logical state machine is single-writer. Terminal failure states preserve +diagnostic identity until a new generation is requested. + +| Current | Event | Next | Required accounting/action | +|---|---|---|---| +| `Absent` | request | `Requested` | allocate/current generation; record priority and request frame | +| `Requested` | prepare succeeds | `Prepared` | publish actual CPU prepared/decoded bytes | +| `Requested` | cancel | `Cancelled` | clear transient bytes; never publish a completion | +| `Requested` | missing | `Missing` | retain negative result only | +| `Requested` | corrupt | `Corrupt` | retain diagnostic result; do not retry silently | +| `Requested` | failure | `Failed` | retain typed failure; do not swallow | +| `Prepared` | queue upload | `UploadPending` | move or add staging/GPU-requested charges | +| `Prepared` | release with no owners | `Retiring` | enqueue logical eviction | +| `UploadPending` | upload succeeds | `Resident` | clear requested/staging charge; publish exact resident charge | +| `UploadPending` | retryable rollback completes | `Prepared` | clear GPU-requested charge; retain CPU data | +| `UploadPending` | cancel/no owners | `Retiring` | invalidate generation; stale completion is ignored | +| `UploadPending` | corrupt/failure | `Corrupt`/`Failed` | clear transient charges; retain diagnostic state | +| `Resident` | acquire/touch | `Resident` | update owner set, priority, frame, and rebuild cost | +| `Resident` | last owner releases | `Resident` | becomes eviction-eligible; physical cache may retain it | +| `Resident` | evict | `Retiring` | invalidate logical availability before physical release | +| `Retiring` | fence release completes | `Absent` | clear retiring bytes and recycle slot with next generation | +| any non-retiring state | session/world generation invalidates | `Retiring` or terminal cancel | reject later stale work | +| terminal failure state | explicit new request | `Requested` on a new generation | prior completion cannot revive the new request | + +Illegal transitions throw in tests and emit a release diagnostic in +production composition; they are never silently coerced. + +## 5. Threading and phase ownership + +The graphical client currently advances update and render on one native window +thread. Slice D nevertheless fixes the contract at the future host boundary: + +### Shared/worker side + +- Decode and preparation workers may only enqueue immutable + `ResidencyObservation` records. +- Workers never mutate the manager ledger, budgets, owners, GL resources, or + render cache collections. +- The observation queue is bounded by active asset generations. Repeated + observations for one `(handle, kind)` coalesce last-writer before drain. +- Cancellation and corrupt/failure completion carry the exact asset generation. + +### Update/resource-maintenance phase + +- `ResidencyManager` is single-writer. +- At most one manager drain and policy pass runs per host tick. +- It applies observations, updates the lease/state ledger, captures domain + facts, and emits bounded `ResidencyTrimRequest` commands. +- Eviction order is: zero owners first, lowest priority first, oldest use + generation/frame first, lowest rebuild cost first, then stable handle index. +- Manager code never waits on decode, file I/O, a GPU fence, or a lock acquired + by the render path. + +### Render-resource phase + +- Specialized owners consume trim requests without calling back into the + manager while holding their internal mutation locks. +- Logical removal occurs before publication of a physical-release request. +- OpenGL non-residency/deletion and arena range return remain render-thread + only and fence delayed through the existing retirement owners. +- Physical completion is observed on the following manager drain. A stale + completion with the wrong handle generation is rejected. + +No render-thread code blocks waiting for the manager, and the manager never +sees a raw GL name or bindless handle. + +## 6. Accounting model + +Every domain reports the following independent values: + +- `LogicalBytes`: immutable metadata and logical entry overhead where measured. +- `CpuPreparedBytes`: parsed/prepared payload retained for reuse. +- `DecodedBytes`: canonical decoded pixels/audio/animation graphs. +- `PinnedBytes`: CPU data that cannot currently be evicted due to a live lease. +- `StagingBytes`: queued or claimed upload payload. +- `GpuRequestedBytes`: physical allocation requested but not yet published. +- `GpuResidentBytes`: currently drawable physical allocation. +- `RetiringBytes`: logically dead physical allocation awaiting a fence/retry. +- `MappedVirtualBytes`: immutable package address space, not committed RAM. +- `BudgetBytes`: the policy ceiling for the relevant charge. +- `CapacityBytes`, `UsedBytes`, `LargestFreeBytes`: allocator facts used to + calculate fragmentation without pretending free capacity is a leak. + +Aggregate totals never add `MappedVirtualBytes` to committed CPU residence and +never add an arena's logical allocation bytes to its physical capacity twice. + +## 7. Budgets and defaults + +`RuntimeOptions` owns environment parsing once. `ResidencyBudgetOptions` +contains byte/count ceilings with the current production values as defaults: + +- object mesh physical: 1 GiB; +- object mesh unowned entries: 50; +- prepared mesh CPU: 128 MiB / 100; +- staging: 128 MiB / 256; +- composite arrays: 128 MiB physical / 64 MiB unowned; +- standalone particle arrays: 32 MiB / 256; +- decoded animations: 64 MiB / 512; +- deferred-alpha retained CPU scratch: 16 MiB aggregate with per-owner floors. + +Environment overrides are diagnostic/startup inputs and reject zero, negative, +overflowing, or malformed values by falling back to the documented default. +The settings quality preset may select a complete budget profile later; Slice D +first exposes the typed runtime target so a settings change is atomic rather +than mutating individual caches independently. Defaults preserve today's +visual radius and cache behavior. + +Multi-session retuning is evidence driven. This slice records actual working +sets under normal, dense, and forced-pressure routes before changing production +defaults below their current values. + +## 8. Delivery checkpoints + +### D1 — Contract and logical ledger + +- Add typed handles, owner tokens, leases, priorities, states, observations, + trim requests, budgets, and aggregate snapshots. +- Implement the transition table and generation-safe owner release. +- Test stale completions, duplicate acquire, owner reuse, asset-slot reuse, + cancellation, corrupt/failure states, and illegal transitions. + +### D2 — Existing-owner adapters + +- Parameterize current mesh, staging, composite, and standalone budgets. +- Add exact snapshot facts to each owner. +- Register them with one manager without changing their physical storage or + fence protocol. +- Cross-check manager GPU totals with `GpuMemoryTracker`, allowing explicitly + enumerated non-cache resources. + +### D3 — Missing policies + +- Bound `RetailAnimationLoader` by count and estimated retained bytes. +- Bring deferred-alpha retained scratch behind a bounded capacity owner. +- Close stale #243 after proving no current bounds cache exists. + +### D4 — Diagnostics and forced pressure + +- Publish aggregate and per-domain budgets/occupancy/fragmentation through the + lifecycle diagnostic artifact. +- Add deterministic forced-pressure tests that exceed every existing ceiling, + drain fence release, and prove convergence. +- Assert manager and physical-owner accounting return to zero at teardown. + +### D5 — Connected gate + +- Run capped, uncapped, and dense connected routes against Slice C. +- Run repeated same-location portal loops and compare first/second/third visit. +- Require no missing texture/mesh, no stale-generation release, no monotonic + owner/resource growth, and graceful shutdown. +- Preserve fixed-camera screenshots and obtain the user's visual gate if the + connected route changes any visible frame. + +## 9. Acceptance + +Slice D closes only when: + +1. All state/ownership/generation tests pass. +2. Forced-pressure tests prove the configured ceilings, not merely a route + that happens to stay below them. +3. The third same-location visit plateaus within the documented tolerance. +4. Repeated portal loops do not grow logical owners, staged work, resident GPU + resources, retiring resources, or parsed animation residence. +5. The complete Release build/test suite is green. +6. The architecture, roadmap, issues, inventory, and durable memory agree with + the implementation. +7. Connected screenshots retain Slice C's visual output. + diff --git a/src/AcDream.App/Rendering/Residency/AssetResidencyTypes.cs b/src/AcDream.App/Rendering/Residency/AssetResidencyTypes.cs new file mode 100644 index 00000000..6d86f406 --- /dev/null +++ b/src/AcDream.App/Rendering/Residency/AssetResidencyTypes.cs @@ -0,0 +1,199 @@ +namespace AcDream.App.Rendering.Residency; + +internal enum ResidencyDomain : byte +{ + ObjectMeshes, + PreparedMeshCpu, + MeshStaging, + GlobalMeshArena, + CompositeTextures, + StandaloneTextures, + PreparedPackage, + Animations, + Audio, + AlphaScratch, +} + +internal enum ResidencyPriority : byte +{ + Speculative, + Far, + Near, + Visible, + DestinationCritical, +} + +internal enum AssetResidencyState : byte +{ + Absent, + Requested, + Prepared, + UploadPending, + Resident, + Retiring, + Cancelled, + Missing, + Corrupt, + Failed, +} + +internal enum ResidencyOwnerKind : byte +{ + Session, + Landblock, + Entity, + ParticleEmitter, + UserInterface, + Tooling, +} + +internal readonly record struct ResidencyAssetKey( + ResidencyDomain Domain, + ulong ContentKey, + ulong Variant = 0); + +internal readonly record struct AssetHandle(uint Index, ushort Generation) +{ + public bool IsValid => Index != 0 && Generation != 0; + + internal AssetReference Untyped => + new(Index, Generation, typeof(TAsset).TypeHandle); +} + +internal readonly record struct OwnerToken(uint Index, ushort Generation) +{ + public bool IsValid => Index != 0 && Generation != 0; +} + +internal readonly record struct AssetLease( + AssetHandle Handle, + OwnerToken Owner); + +internal readonly record struct AssetReference( + uint Index, + ushort Generation, + RuntimeTypeHandle AssetType) +{ + public bool IsValid => + Index != 0 + && Generation != 0 + && !AssetType.Equals(default(RuntimeTypeHandle)); +} + +internal readonly record struct ResidencyCharges( + long LogicalBytes = 0, + long CpuPreparedBytes = 0, + long DecodedBytes = 0, + long PinnedBytes = 0, + long StagingBytes = 0, + long GpuRequestedBytes = 0, + long GpuResidentBytes = 0, + long RetiringBytes = 0, + long MappedVirtualBytes = 0) +{ + public static ResidencyCharges Zero => default; + + public long CommittedCpuBytes => checked( + LogicalBytes + + CpuPreparedBytes + + DecodedBytes + + StagingBytes); + + public long PhysicalGpuBytes => checked( + GpuRequestedBytes + + GpuResidentBytes + + RetiringBytes); + + public bool IsZero => this == default; + + public void Validate() + { + ArgumentOutOfRangeException.ThrowIfNegative(LogicalBytes); + ArgumentOutOfRangeException.ThrowIfNegative(CpuPreparedBytes); + ArgumentOutOfRangeException.ThrowIfNegative(DecodedBytes); + ArgumentOutOfRangeException.ThrowIfNegative(PinnedBytes); + ArgumentOutOfRangeException.ThrowIfNegative(StagingBytes); + ArgumentOutOfRangeException.ThrowIfNegative(GpuRequestedBytes); + ArgumentOutOfRangeException.ThrowIfNegative(GpuResidentBytes); + ArgumentOutOfRangeException.ThrowIfNegative(RetiringBytes); + ArgumentOutOfRangeException.ThrowIfNegative(MappedVirtualBytes); + } + + public static ResidencyCharges operator +( + ResidencyCharges left, + ResidencyCharges right) => + new( + checked(left.LogicalBytes + right.LogicalBytes), + checked(left.CpuPreparedBytes + right.CpuPreparedBytes), + checked(left.DecodedBytes + right.DecodedBytes), + checked(left.PinnedBytes + right.PinnedBytes), + checked(left.StagingBytes + right.StagingBytes), + checked(left.GpuRequestedBytes + right.GpuRequestedBytes), + checked(left.GpuResidentBytes + right.GpuResidentBytes), + checked(left.RetiringBytes + right.RetiringBytes), + checked(left.MappedVirtualBytes + right.MappedVirtualBytes)); +} + +internal readonly record struct ResidencyEntrySnapshot( + AssetReference Asset, + ResidencyAssetKey Key, + AssetResidencyState State, + ResidencyPriority Priority, + ulong WorldGeneration, + long LastUsedFrame, + long RebuildCost, + int OwnerCount, + ResidencyCharges Charges, + string? Failure); + +internal readonly record struct ResidencyTrimRequest( + AssetReference Asset, + ResidencyAssetKey Key, + ResidencyCharges Charges); + +internal readonly record struct ResidencyDomainSnapshot( + ResidencyDomain Domain, + int EntryCount, + int OwnerCount, + ResidencyCharges Charges, + long BudgetBytes = 0, + long CapacityBytes = 0, + long UsedBytes = 0, + long LargestFreeBytes = 0, + long Evictions = 0) +{ + public long FreeBytes => Math.Max(0, CapacityBytes - UsedBytes); + + public long FragmentedFreeBytes => + Math.Max(0, FreeBytes - LargestFreeBytes); +} + +internal readonly record struct ResidencySnapshot( + IReadOnlyList Domains, + ResidencyCharges TotalCharges) +{ + public ResidencyDomainSnapshot Get(ResidencyDomain domain) => + Domains.FirstOrDefault(snapshot => snapshot.Domain == domain); +} + +internal interface IResidencyDomainSource +{ + ResidencyDomain Domain { get; } + + ResidencyDomainSnapshot CaptureResidency(); +} + +internal enum ResidencyObservationKind : byte +{ + Transition, + Touch, +} + +internal readonly record struct ResidencyObservation( + AssetReference Asset, + ResidencyObservationKind Kind, + AssetResidencyState State, + ResidencyCharges Charges, + ResidencyPriority Priority, + long Frame, + string? Failure = null); diff --git a/src/AcDream.App/Rendering/Residency/ResidencyBudgetOptions.cs b/src/AcDream.App/Rendering/Residency/ResidencyBudgetOptions.cs new file mode 100644 index 00000000..44a74fe6 --- /dev/null +++ b/src/AcDream.App/Rendering/Residency/ResidencyBudgetOptions.cs @@ -0,0 +1,113 @@ +using System.Globalization; + +namespace AcDream.App.Rendering.Residency; + +/// +/// Startup-time residency ceilings. Values preserve the pre-Slice-D cache +/// behavior by default and are changed atomically as one profile. +/// +public sealed record ResidencyBudgetOptions( + long ObjectMeshGpuBytes, + int ObjectMeshUnownedEntries, + long PreparedMeshCpuBytes, + int PreparedMeshCpuEntries, + long MeshStagingBytes, + int MeshStagingEntries, + long CompositePhysicalBytes, + long CompositeUnownedBytes, + long StandaloneUnownedBytes, + int StandaloneUnownedEntries, + long AnimationBytes, + int AnimationEntries, + long AlphaScratchBytes) +{ + public const long MiB = 1024L * 1024L; + + public static ResidencyBudgetOptions Default { get; } = new( + ObjectMeshGpuBytes: 1024 * MiB, + ObjectMeshUnownedEntries: 50, + PreparedMeshCpuBytes: 128 * MiB, + PreparedMeshCpuEntries: 100, + MeshStagingBytes: 128 * MiB, + MeshStagingEntries: 256, + CompositePhysicalBytes: 128 * MiB, + CompositeUnownedBytes: 64 * MiB, + StandaloneUnownedBytes: 32 * MiB, + StandaloneUnownedEntries: 256, + AnimationBytes: 64 * MiB, + AnimationEntries: 512, + AlphaScratchBytes: 16 * MiB); + + internal static ResidencyBudgetOptions Parse( + Func env) + { + ArgumentNullException.ThrowIfNull(env); + ResidencyBudgetOptions defaults = Default; + return new ResidencyBudgetOptions( + ObjectMeshGpuBytes: ParseMiB( + env("ACDREAM_RESIDENCY_MESH_GPU_MIB"), + defaults.ObjectMeshGpuBytes), + ObjectMeshUnownedEntries: ParseCount( + env("ACDREAM_RESIDENCY_MESH_UNOWNED_ENTRIES"), + defaults.ObjectMeshUnownedEntries), + PreparedMeshCpuBytes: ParseMiB( + env("ACDREAM_RESIDENCY_PREPARED_MESH_MIB"), + defaults.PreparedMeshCpuBytes), + PreparedMeshCpuEntries: ParseCount( + env("ACDREAM_RESIDENCY_PREPARED_MESH_ENTRIES"), + defaults.PreparedMeshCpuEntries), + MeshStagingBytes: ParseMiB( + env("ACDREAM_RESIDENCY_MESH_STAGING_MIB"), + defaults.MeshStagingBytes), + MeshStagingEntries: ParseCount( + env("ACDREAM_RESIDENCY_MESH_STAGING_ENTRIES"), + defaults.MeshStagingEntries), + CompositePhysicalBytes: ParseMiB( + env("ACDREAM_RESIDENCY_COMPOSITE_PHYSICAL_MIB"), + defaults.CompositePhysicalBytes), + CompositeUnownedBytes: ParseMiB( + env("ACDREAM_RESIDENCY_COMPOSITE_UNOWNED_MIB"), + defaults.CompositeUnownedBytes), + StandaloneUnownedBytes: ParseMiB( + env("ACDREAM_RESIDENCY_STANDALONE_UNOWNED_MIB"), + defaults.StandaloneUnownedBytes), + StandaloneUnownedEntries: ParseCount( + env("ACDREAM_RESIDENCY_STANDALONE_UNOWNED_ENTRIES"), + defaults.StandaloneUnownedEntries), + AnimationBytes: ParseMiB( + env("ACDREAM_RESIDENCY_ANIMATION_MIB"), + defaults.AnimationBytes), + AnimationEntries: ParseCount( + env("ACDREAM_RESIDENCY_ANIMATION_ENTRIES"), + defaults.AnimationEntries), + AlphaScratchBytes: ParseMiB( + env("ACDREAM_RESIDENCY_ALPHA_SCRATCH_MIB"), + defaults.AlphaScratchBytes)); + } + + 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 int ParseCount(string? value, int fallback) => + int.TryParse( + value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int count) + && count > 0 + ? count + : fallback; +} + diff --git a/src/AcDream.App/Rendering/Residency/ResidencyManager.cs b/src/AcDream.App/Rendering/Residency/ResidencyManager.cs new file mode 100644 index 00000000..3e357360 --- /dev/null +++ b/src/AcDream.App/Rendering/Residency/ResidencyManager.cs @@ -0,0 +1,614 @@ +namespace AcDream.App.Rendering.Residency; + +/// +/// Single-writer logical residency owner. It has no dependency on OpenGL and +/// never stores physical resource names. +/// +internal sealed class ResidencyManager +{ + private sealed class AssetSlot + { + public required RuntimeTypeHandle AssetType { get; init; } + public required ushort Generation { get; init; } + public required ResidencyAssetKey Key { get; init; } + public required ulong WorldGeneration { get; init; } + public AssetResidencyState State { get; set; } + public ResidencyPriority Priority { get; set; } + public long LastUsedFrame { get; set; } + public long RebuildCost { get; set; } + public ResidencyCharges Charges { get; set; } + public string? Failure { get; set; } + public HashSet Owners { get; } = []; + } + + private sealed class OwnerSlot + { + public required ushort Generation { get; init; } + public required ResidencyOwnerKind Kind { get; init; } + public required ulong LogicalId { get; init; } + public required ulong WorldGeneration { get; init; } + public HashSet Assets { get; } = []; + } + + private readonly record struct TypedAssetKey( + RuntimeTypeHandle AssetType, + ResidencyAssetKey Key); + + private readonly List _assets = [null]; + private readonly List _assetGenerations = [0]; + private readonly Stack _freeAssets = []; + private readonly Dictionary _assetByKey = []; + + private readonly List _owners = [null]; + private readonly List _ownerGenerations = [0]; + private readonly Stack _freeOwners = []; + + private readonly List _domainSources = []; + private readonly List _observationScratch = []; + private readonly List _snapshotScratch = []; + + public int ActiveAssetCount => _assetByKey.Count; + + public int ActiveOwnerCount => _owners.Count(owner => owner is not null); + + public void RegisterDomainSource(IResidencyDomainSource source) + { + ArgumentNullException.ThrowIfNull(source); + if (_domainSources.Any(candidate => candidate.Domain == source.Domain)) + { + throw new InvalidOperationException( + $"Residency domain {source.Domain} is already registered."); + } + _domainSources.Add(source); + } + + public OwnerToken CreateOwner( + ResidencyOwnerKind kind, + ulong logicalId, + ulong worldGeneration) + { + uint index = AllocateIndex(_owners, _ownerGenerations, _freeOwners); + ushort generation = NextGeneration(_ownerGenerations, index); + _owners[checked((int)index)] = new OwnerSlot + { + Generation = generation, + Kind = kind, + LogicalId = logicalId, + WorldGeneration = worldGeneration, + }; + return new OwnerToken(index, generation); + } + + public bool RetireOwner(OwnerToken owner) + { + if (!TryGetOwner(owner, out OwnerSlot slot)) + return false; + + AssetReference[] assets = slot.Assets.ToArray(); + for (int i = 0; i < assets.Length; i++) + { + if (TryGetAsset(assets[i], out AssetSlot asset)) + asset.Owners.Remove(owner); + } + + _owners[checked((int)owner.Index)] = null; + _freeOwners.Push(owner.Index); + return true; + } + + public AssetHandle Request( + ResidencyAssetKey key, + ResidencyPriority priority, + ulong worldGeneration, + long frame, + long rebuildCost = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(frame); + ArgumentOutOfRangeException.ThrowIfNegative(rebuildCost); + RuntimeTypeHandle type = typeof(TAsset).TypeHandle; + var typedKey = new TypedAssetKey(type, key); + if (_assetByKey.TryGetValue(typedKey, out uint existingIndex)) + { + AssetSlot existing = _assets[checked((int)existingIndex)] + ?? throw new InvalidOperationException( + "Residency key index did not resolve to a live slot."); + if (IsTerminal(existing.State)) + { + RecycleAsset(existingIndex, existing); + } + else + { + existing.Priority = Max(existing.Priority, priority); + existing.LastUsedFrame = Math.Max(existing.LastUsedFrame, frame); + existing.RebuildCost = Math.Max(existing.RebuildCost, rebuildCost); + return new AssetHandle( + existingIndex, + existing.Generation); + } + } + + uint index = AllocateIndex(_assets, _assetGenerations, _freeAssets); + ushort generation = NextGeneration(_assetGenerations, index); + var slot = new AssetSlot + { + AssetType = type, + Generation = generation, + Key = key, + WorldGeneration = worldGeneration, + State = AssetResidencyState.Requested, + Priority = priority, + LastUsedFrame = frame, + RebuildCost = rebuildCost, + }; + _assets[checked((int)index)] = slot; + _assetByKey.Add(typedKey, index); + return new AssetHandle(index, generation); + } + + public AssetLease Acquire( + AssetHandle handle, + OwnerToken owner, + ResidencyPriority priority, + long frame) + { + AssetSlot asset = GetAsset(handle); + OwnerSlot ownerSlot = GetOwner(owner); + if (asset.State is AssetResidencyState.Retiring + or AssetResidencyState.Cancelled + or AssetResidencyState.Missing + or AssetResidencyState.Corrupt + or AssetResidencyState.Failed) + { + throw new InvalidOperationException( + $"Cannot acquire an asset in {asset.State} state."); + } + if (ownerSlot.WorldGeneration != asset.WorldGeneration + && ownerSlot.Kind is not ResidencyOwnerKind.Session + and not ResidencyOwnerKind.UserInterface + and not ResidencyOwnerKind.Tooling) + { + throw new InvalidOperationException( + "A world-scoped owner cannot acquire an asset from another generation."); + } + + var lease = new AssetLease(handle, owner); + if (asset.Owners.Add(owner)) + ownerSlot.Assets.Add(handle.Untyped); + asset.Priority = Max(asset.Priority, priority); + asset.LastUsedFrame = Math.Max(asset.LastUsedFrame, frame); + return lease; + } + + public bool Release(AssetLease lease) + { + if (!TryGetAsset(lease.Handle.Untyped, out AssetSlot asset) + || !TryGetOwner(lease.Owner, out OwnerSlot owner)) + { + return false; + } + + bool removed = asset.Owners.Remove(lease.Owner); + if (removed) + { + owner.Assets.Remove(lease.Handle.Untyped); + if (asset.Owners.Count == 0) + asset.Priority = ResidencyPriority.Speculative; + } + return removed; + } + + public bool Touch( + AssetHandle handle, + ResidencyPriority priority, + long frame) + { + if (!TryGetAsset(handle.Untyped, out AssetSlot asset)) + return false; + asset.Priority = Max(asset.Priority, priority); + asset.LastUsedFrame = Math.Max(asset.LastUsedFrame, frame); + return true; + } + + public bool Transition( + AssetHandle handle, + AssetResidencyState next, + ResidencyCharges charges, + long frame, + string? failure = null) => + Transition(handle.Untyped, next, charges, frame, failure); + + public bool CompleteRetirement(AssetHandle handle) => + CompleteRetirement(handle.Untyped); + + public bool CompleteRetirement(AssetReference asset) + { + if (!TryGetAsset(asset, out AssetSlot slot)) + return false; + if (slot.State != AssetResidencyState.Retiring) + { + throw new InvalidOperationException( + $"Only a retiring asset can become absent; current={slot.State}."); + } + if (slot.Owners.Count != 0) + throw new InvalidOperationException( + "A retiring asset retained logical owners."); + + RecycleAsset(asset.Index, slot); + return true; + } + + public bool TryGetSnapshot( + AssetHandle handle, + out ResidencyEntrySnapshot snapshot) => + TryGetSnapshot(handle.Untyped, out snapshot); + + public int Drain(ResidencyObservationJournal journal) + { + ArgumentNullException.ThrowIfNull(journal); + _observationScratch.Clear(); + journal.DrainTo(_observationScratch); + _observationScratch.Sort(static (left, right) => + { + int byIndex = left.Asset.Index.CompareTo(right.Asset.Index); + if (byIndex != 0) + return byIndex; + return left.Kind.CompareTo(right.Kind); + }); + + int applied = 0; + for (int i = 0; i < _observationScratch.Count; i++) + { + ResidencyObservation observation = _observationScratch[i]; + bool accepted = observation.Kind switch + { + ResidencyObservationKind.Touch => + Touch( + observation.Asset, + observation.Priority, + observation.Frame), + ResidencyObservationKind.Transition => + Transition( + observation.Asset, + observation.State, + observation.Charges, + observation.Frame, + observation.Failure), + _ => throw new ArgumentOutOfRangeException( + nameof(observation), + observation.Kind, + "unknown residency observation kind"), + }; + if (accepted) + applied++; + } + return applied; + } + + public IReadOnlyList SelectEvictions( + ResidencyDomain domain, + long bytesToRelease, + int maximumCount) + { + ArgumentOutOfRangeException.ThrowIfNegative(bytesToRelease); + ArgumentOutOfRangeException.ThrowIfNegative(maximumCount); + if (bytesToRelease == 0 || maximumCount == 0) + return Array.Empty(); + + List<(uint Index, AssetSlot Slot)> candidates = []; + for (uint index = 1; index < _assets.Count; index++) + { + AssetSlot? slot = _assets[checked((int)index)]; + if (slot is null + || slot.Key.Domain != domain + || slot.Owners.Count != 0 + || slot.State is not (AssetResidencyState.Resident + or AssetResidencyState.Prepared)) + { + continue; + } + candidates.Add((index, slot)); + } + + candidates.Sort(static (left, right) => + { + int order = left.Slot.Priority.CompareTo(right.Slot.Priority); + if (order != 0) + return order; + order = left.Slot.LastUsedFrame.CompareTo(right.Slot.LastUsedFrame); + if (order != 0) + return order; + order = left.Slot.RebuildCost.CompareTo(right.Slot.RebuildCost); + return order != 0 + ? order + : left.Index.CompareTo(right.Index); + }); + + List requests = []; + long selectedBytes = 0; + for (int i = 0; + i < candidates.Count + && requests.Count < maximumCount + && selectedBytes < bytesToRelease; + i++) + { + (uint index, AssetSlot slot) = candidates[i]; + ResidencyCharges retiring = ToRetiringCharges(slot.Charges); + var asset = new AssetReference( + index, + slot.Generation, + slot.AssetType); + Transition( + asset, + AssetResidencyState.Retiring, + retiring, + slot.LastUsedFrame); + requests.Add(new ResidencyTrimRequest(asset, slot.Key, retiring)); + selectedBytes = checked( + selectedBytes + Math.Max( + 1, + retiring.RetiringBytes + + retiring.CommittedCpuBytes)); + } + return requests; + } + + public ResidencySnapshot CaptureSnapshot() + { + _snapshotScratch.Clear(); + for (int i = 0; i < _domainSources.Count; i++) + _snapshotScratch.Add(_domainSources[i].CaptureResidency()); + + ResidencyCharges totals = default; + for (uint index = 1; index < _assets.Count; index++) + { + AssetSlot? slot = _assets[checked((int)index)]; + if (slot is not null) + totals += slot.Charges; + } + + for (int i = 0; i < _snapshotScratch.Count; i++) + totals += _snapshotScratch[i].Charges; + + return new ResidencySnapshot(_snapshotScratch.ToArray(), totals); + } + + private bool Transition( + AssetReference asset, + AssetResidencyState next, + ResidencyCharges charges, + long frame, + string? failure = null) + { + if (!TryGetAsset(asset, out AssetSlot slot)) + return false; + ArgumentOutOfRangeException.ThrowIfNegative(frame); + charges.Validate(); + ValidateTransition(slot.State, next); + ValidateCharges(next, charges); + if (next == AssetResidencyState.Retiring && slot.Owners.Count != 0) + { + throw new InvalidOperationException( + "An owned asset cannot enter retiring state."); + } + + slot.State = next; + slot.Charges = charges; + slot.LastUsedFrame = Math.Max(slot.LastUsedFrame, frame); + slot.Failure = next is AssetResidencyState.Corrupt + or AssetResidencyState.Failed + ? failure + : null; + if (IsTerminal(next)) + DetachAllOwners(asset, slot); + return true; + } + + private bool Touch( + AssetReference asset, + ResidencyPriority priority, + long frame) + { + if (!TryGetAsset(asset, out AssetSlot slot)) + return false; + slot.Priority = Max(slot.Priority, priority); + slot.LastUsedFrame = Math.Max(slot.LastUsedFrame, frame); + return true; + } + + private bool TryGetSnapshot( + AssetReference asset, + out ResidencyEntrySnapshot snapshot) + { + if (!TryGetAsset(asset, out AssetSlot slot)) + { + snapshot = default; + return false; + } + + snapshot = new ResidencyEntrySnapshot( + asset, + slot.Key, + slot.State, + slot.Priority, + slot.WorldGeneration, + slot.LastUsedFrame, + slot.RebuildCost, + slot.Owners.Count, + slot.Charges, + slot.Failure); + return true; + } + + private AssetSlot GetAsset(AssetHandle handle) => + TryGetAsset(handle.Untyped, out AssetSlot slot) + ? slot + : throw new InvalidOperationException("Asset handle is stale or invalid."); + + private OwnerSlot GetOwner(OwnerToken owner) => + TryGetOwner(owner, out OwnerSlot slot) + ? slot + : throw new InvalidOperationException("Owner token is stale or invalid."); + + private bool TryGetAsset( + AssetReference asset, + out AssetSlot slot) + { + slot = null!; + if (!asset.IsValid || asset.Index >= _assets.Count) + return false; + AssetSlot? candidate = _assets[checked((int)asset.Index)]; + if (candidate is null + || candidate.Generation != asset.Generation + || !candidate.AssetType.Equals(asset.AssetType)) + { + return false; + } + slot = candidate; + return true; + } + + private bool TryGetOwner(OwnerToken owner, out OwnerSlot slot) + { + slot = null!; + if (!owner.IsValid || owner.Index >= _owners.Count) + return false; + OwnerSlot? candidate = _owners[checked((int)owner.Index)]; + if (candidate is null || candidate.Generation != owner.Generation) + return false; + slot = candidate; + return true; + } + + private void RecycleAsset(uint index, AssetSlot slot) + { + if (slot.Owners.Count != 0) + throw new InvalidOperationException( + "An asset cannot be recycled while owners remain."); + _assetByKey.Remove(new TypedAssetKey(slot.AssetType, slot.Key)); + _assets[checked((int)index)] = null; + _freeAssets.Push(index); + } + + private void DetachAllOwners(AssetReference asset, AssetSlot slot) + { + if (slot.Owners.Count == 0) + return; + OwnerToken[] owners = slot.Owners.ToArray(); + for (int i = 0; i < owners.Length; i++) + { + if (TryGetOwner(owners[i], out OwnerSlot owner)) + owner.Assets.Remove(asset); + } + slot.Owners.Clear(); + } + + private static uint AllocateIndex( + List slots, + List generations, + Stack free) + where T : class + { + if (free.TryPop(out uint reused)) + return reused; + uint index = checked((uint)slots.Count); + slots.Add(null); + generations.Add(0); + return index; + } + + private static ushort NextGeneration( + List generations, + uint index) + { + ushort generation = unchecked( + (ushort)(generations[checked((int)index)] + 1)); + if (generation == 0) + generation = 1; + generations[checked((int)index)] = generation; + return generation; + } + + private static ResidencyPriority Max( + ResidencyPriority left, + ResidencyPriority right) => + left >= right ? left : right; + + private static bool IsTerminal(AssetResidencyState state) => + state is AssetResidencyState.Cancelled + or AssetResidencyState.Missing + or AssetResidencyState.Corrupt + or AssetResidencyState.Failed; + + private static void ValidateTransition( + AssetResidencyState current, + AssetResidencyState next) + { + bool legal = current switch + { + AssetResidencyState.Requested => + next is AssetResidencyState.Prepared + or AssetResidencyState.Cancelled + or AssetResidencyState.Missing + or AssetResidencyState.Corrupt + or AssetResidencyState.Failed + or AssetResidencyState.Retiring, + AssetResidencyState.Prepared => + next is AssetResidencyState.UploadPending + or AssetResidencyState.Retiring, + AssetResidencyState.UploadPending => + next is AssetResidencyState.Resident + or AssetResidencyState.Prepared + or AssetResidencyState.Retiring + or AssetResidencyState.Cancelled + or AssetResidencyState.Corrupt + or AssetResidencyState.Failed, + AssetResidencyState.Resident => + next is AssetResidencyState.Retiring, + _ => false, + }; + if (!legal) + { + throw new InvalidOperationException( + $"Illegal residency transition {current} -> {next}."); + } + } + + private static void ValidateCharges( + AssetResidencyState state, + ResidencyCharges charges) + { + if (state is AssetResidencyState.Cancelled + or AssetResidencyState.Missing + or AssetResidencyState.Corrupt + or AssetResidencyState.Failed) + { + if (!charges.IsZero) + { + throw new InvalidOperationException( + $"Terminal state {state} cannot retain residency charges."); + } + return; + } + + if (state == AssetResidencyState.Retiring + && (charges.GpuRequestedBytes != 0 + || charges.GpuResidentBytes != 0 + || charges.StagingBytes != 0)) + { + throw new InvalidOperationException( + "Retiring charges must move transient and resident GPU bytes into RetiringBytes."); + } + } + + private static ResidencyCharges ToRetiringCharges( + ResidencyCharges current) => + new( + LogicalBytes: current.LogicalBytes, + CpuPreparedBytes: current.CpuPreparedBytes, + DecodedBytes: current.DecodedBytes, + PinnedBytes: current.PinnedBytes, + RetiringBytes: checked( + current.GpuRequestedBytes + + current.GpuResidentBytes + + current.RetiringBytes)); +} diff --git a/src/AcDream.App/Rendering/Residency/ResidencyObservationJournal.cs b/src/AcDream.App/Rendering/Residency/ResidencyObservationJournal.cs new file mode 100644 index 00000000..f36ebd74 --- /dev/null +++ b/src/AcDream.App/Rendering/Residency/ResidencyObservationJournal.cs @@ -0,0 +1,74 @@ +using System.Collections.Concurrent; + +namespace AcDream.App.Rendering.Residency; + +/// +/// Worker-safe, coalescing observation seam. The single update-thread owner +/// drains immutable observations into . +/// +internal sealed class ResidencyObservationJournal +{ + private readonly ConcurrentDictionary< + (AssetReference Asset, ResidencyObservationKind Kind), + ResidencyObservation> _pending = new(); + private readonly int _maximumEntries; + + public ResidencyObservationJournal(int maximumEntries) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumEntries, 1); + _maximumEntries = maximumEntries; + } + + public int Count => _pending.Count; + + public bool TryPublish(in ResidencyObservation observation) + { + if (!observation.Asset.IsValid) + throw new ArgumentException( + "Residency observations require a valid asset reference.", + nameof(observation)); + observation.Charges.Validate(); + + var key = (observation.Asset, observation.Kind); + while (true) + { + if (_pending.TryGetValue(key, out ResidencyObservation current)) + { + // Network/decode callbacks may arrive concurrently. Preserve + // the newest immutable fact instead of letting a late older + // callback overwrite it. + if (observation.Frame < current.Frame) + return true; + if (_pending.TryUpdate(key, observation, current)) + return true; + continue; + } + + if (_pending.Count >= _maximumEntries) + return false; + if (_pending.TryAdd(key, observation)) + return true; + } + } + + public int DrainTo(List destination) + { + ArgumentNullException.ThrowIfNull(destination); + int start = destination.Count; + foreach (KeyValuePair< + (AssetReference Asset, ResidencyObservationKind Kind), + ResidencyObservation> pair in _pending) + { + // Remove only the exact value enumerated. If a worker replaced it + // after enumeration, the newer value remains for the next drain. + if (((ICollection>)_pending).Remove(pair)) + { + destination.Add(pair.Value); + } + } + + return destination.Count - start; + } +} diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 70069e4a..5166d10c 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.IO; +using AcDream.App.Rendering.Residency; namespace AcDream.App; @@ -50,7 +51,8 @@ public sealed record RuntimeOptions( string? UiProbeScript, string? AutomationArtifactDirectory, float FogStartMultiplier, - float FogEndMultiplier) + float FogEndMultiplier, + ResidencyBudgetOptions ResidencyBudgets) { /// /// Build options from the process environment. Used by @@ -107,7 +109,8 @@ public sealed record RuntimeOptions( AutomationArtifactDirectory: NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")), FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f, - FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f); + FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f, + ResidencyBudgets: ResidencyBudgetOptions.Parse(env)); } /// True iff live-mode credentials are present and valid for connecting. diff --git a/tests/AcDream.App.Tests/Rendering/Residency/ResidencyManagerTests.cs b/tests/AcDream.App.Tests/Rendering/Residency/ResidencyManagerTests.cs new file mode 100644 index 00000000..df388955 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Residency/ResidencyManagerTests.cs @@ -0,0 +1,390 @@ +using AcDream.App.Rendering.Residency; + +namespace AcDream.App.Tests.Rendering.Residency; + +public sealed class ResidencyManagerTests +{ + private sealed class MeshAsset; + private sealed class TextureAsset; + + [Fact] + public void HandlesAndOwnersReserveZeroAsInvalid() + { + var manager = new ResidencyManager(); + + OwnerToken owner = manager.CreateOwner( + ResidencyOwnerKind.Entity, + logicalId: 42, + worldGeneration: 7); + AssetHandle asset = manager.Request( + new ResidencyAssetKey(ResidencyDomain.ObjectMeshes, 0x01020304), + ResidencyPriority.Visible, + worldGeneration: 7, + frame: 10); + + Assert.True(owner.IsValid); + Assert.True(asset.IsValid); + Assert.NotEqual(0u, owner.Index); + Assert.NotEqual(0u, asset.Index); + Assert.NotEqual((ushort)0, owner.Generation); + Assert.NotEqual((ushort)0, asset.Generation); + } + + [Fact] + public void DuplicateAcquireIsIdempotentAndSingleReleaseOwnsLease() + { + var manager = new ResidencyManager(); + OwnerToken owner = manager.CreateOwner( + ResidencyOwnerKind.Entity, + logicalId: 42, + worldGeneration: 7); + AssetHandle asset = manager.Request( + new ResidencyAssetKey(ResidencyDomain.ObjectMeshes, 1), + ResidencyPriority.Near, + worldGeneration: 7, + frame: 1); + + AssetLease first = manager.Acquire( + asset, + owner, + ResidencyPriority.Visible, + frame: 2); + AssetLease duplicate = manager.Acquire( + asset, + owner, + ResidencyPriority.Visible, + frame: 3); + + Assert.Equal(first, duplicate); + Assert.True(manager.TryGetSnapshot(asset, out var snapshot)); + Assert.Equal(1, snapshot.OwnerCount); + Assert.Equal(3, snapshot.LastUsedFrame); + Assert.True(manager.Release(first)); + Assert.False(manager.Release(duplicate)); + } + + [Fact] + public void RetiringAssetCannotBeAcquiredAndRecyclesWithNewGeneration() + { + var manager = new ResidencyManager(); + ResidencyAssetKey key = + new(ResidencyDomain.ObjectMeshes, 0x02000001); + AssetHandle first = manager.Request( + key, + ResidencyPriority.Far, + worldGeneration: 1, + frame: 0); + manager.Transition( + first, + AssetResidencyState.Prepared, + new ResidencyCharges(CpuPreparedBytes: 64), + frame: 1); + manager.Transition( + first, + AssetResidencyState.Retiring, + new ResidencyCharges(), + frame: 2); + + Assert.True(manager.CompleteRetirement(first)); + AssetHandle replacement = manager.Request( + key, + ResidencyPriority.Visible, + worldGeneration: 2, + frame: 3); + + Assert.Equal(first.Index, replacement.Index); + Assert.NotEqual(first.Generation, replacement.Generation); + Assert.False(manager.Touch(first, ResidencyPriority.Visible, frame: 4)); + Assert.False(manager.CompleteRetirement(first)); + } + + [Theory] + [InlineData((int)AssetResidencyState.Cancelled)] + [InlineData((int)AssetResidencyState.Missing)] + [InlineData((int)AssetResidencyState.Corrupt)] + [InlineData((int)AssetResidencyState.Failed)] + public void TerminalCompletionDetachesOwnersAndRestartsAsNewGeneration( + int terminalValue) + { + var terminal = (AssetResidencyState)terminalValue; + var manager = new ResidencyManager(); + OwnerToken owner = manager.CreateOwner( + ResidencyOwnerKind.Landblock, + logicalId: 0x1234, + worldGeneration: 5); + ResidencyAssetKey key = + new(ResidencyDomain.PreparedMeshCpu, 7); + AssetHandle first = manager.Request( + key, + ResidencyPriority.DestinationCritical, + worldGeneration: 5, + frame: 0); + manager.Acquire( + first, + owner, + ResidencyPriority.DestinationCritical, + frame: 0); + + manager.Transition( + first, + terminal, + ResidencyCharges.Zero, + frame: 1, + failure: terminal is AssetResidencyState.Corrupt + or AssetResidencyState.Failed + ? "fixture" + : null); + Assert.True(manager.TryGetSnapshot(first, out var terminalSnapshot)); + Assert.Equal(0, terminalSnapshot.OwnerCount); + + AssetHandle replacement = manager.Request( + key, + ResidencyPriority.Near, + worldGeneration: 5, + frame: 2); + Assert.Equal(first.Index, replacement.Index); + Assert.NotEqual(first.Generation, replacement.Generation); + Assert.False(manager.Release(new AssetLease(first, owner))); + } + + [Fact] + public void RetiringRejectsLiveOwners() + { + var manager = new ResidencyManager(); + OwnerToken owner = manager.CreateOwner( + ResidencyOwnerKind.Entity, + logicalId: 1, + worldGeneration: 1); + AssetHandle asset = manager.Request( + new ResidencyAssetKey(ResidencyDomain.ObjectMeshes, 1), + ResidencyPriority.Visible, + worldGeneration: 1, + frame: 0); + manager.Acquire(asset, owner, ResidencyPriority.Visible, frame: 0); + manager.Transition( + asset, + AssetResidencyState.Prepared, + new ResidencyCharges(CpuPreparedBytes: 10), + frame: 1); + + InvalidOperationException error = Assert.Throws( + () => manager.Transition( + asset, + AssetResidencyState.Retiring, + ResidencyCharges.Zero, + frame: 2)); + + Assert.Contains("owned asset", error.Message); + } + + [Fact] + public void IllegalTransitionAndTerminalChargesFailLoudly() + { + var manager = new ResidencyManager(); + AssetHandle asset = manager.Request( + new ResidencyAssetKey(ResidencyDomain.ObjectMeshes, 1), + ResidencyPriority.Far, + worldGeneration: 1, + frame: 0); + + Assert.Throws(() => manager.Transition( + asset, + AssetResidencyState.Resident, + new ResidencyCharges(GpuResidentBytes: 1), + frame: 1)); + Assert.Throws(() => manager.Transition( + asset, + AssetResidencyState.Failed, + new ResidencyCharges(CpuPreparedBytes: 1), + frame: 1)); + } + + [Fact] + public void OwnerGenerationPreventsStaleReleaseOfReplacementOwner() + { + var manager = new ResidencyManager(); + OwnerToken first = manager.CreateOwner( + ResidencyOwnerKind.Entity, + logicalId: 1, + worldGeneration: 1); + Assert.True(manager.RetireOwner(first)); + OwnerToken replacement = manager.CreateOwner( + ResidencyOwnerKind.Entity, + logicalId: 2, + worldGeneration: 1); + + Assert.Equal(first.Index, replacement.Index); + Assert.NotEqual(first.Generation, replacement.Generation); + Assert.False(manager.RetireOwner(first)); + Assert.Equal(1, manager.ActiveOwnerCount); + } + + [Fact] + public void WorldScopedOwnerCannotCrossGeneration() + { + var manager = new ResidencyManager(); + OwnerToken owner = manager.CreateOwner( + ResidencyOwnerKind.Entity, + logicalId: 1, + worldGeneration: 2); + AssetHandle asset = manager.Request( + new ResidencyAssetKey(ResidencyDomain.ObjectMeshes, 1), + ResidencyPriority.Near, + worldGeneration: 3, + frame: 0); + + Assert.Throws(() => manager.Acquire( + asset, + owner, + ResidencyPriority.Near, + frame: 0)); + } + + [Fact] + public void EvictionIsOwnerSafePriorityThenAgeThenCostThenIndex() + { + var manager = new ResidencyManager(); + AssetHandle farOldExpensive = Resident( + manager, 1, ResidencyPriority.Far, frame: 1, bytes: 10, rebuild: 9); + AssetHandle speculativeNew = Resident( + manager, 2, ResidencyPriority.Speculative, frame: 9, bytes: 20, rebuild: 9); + AssetHandle farOldCheap = Resident( + manager, 3, ResidencyPriority.Far, frame: 1, bytes: 30, rebuild: 1); + OwnerToken owner = manager.CreateOwner( + ResidencyOwnerKind.Entity, + logicalId: 9, + worldGeneration: 1); + manager.Acquire( + speculativeNew, + owner, + ResidencyPriority.Visible, + frame: 10); + + IReadOnlyList requests = + manager.SelectEvictions( + ResidencyDomain.ObjectMeshes, + bytesToRelease: 35, + maximumCount: 3); + + Assert.Collection( + requests, + first => Assert.Equal(farOldCheap.Untyped, first.Asset), + second => Assert.Equal(farOldExpensive.Untyped, second.Asset)); + Assert.DoesNotContain( + requests, + request => request.Asset == speculativeNew.Untyped); + } + + [Fact] + public void JournalCoalescesAndStaleObservationCannotReviveReplacement() + { + var manager = new ResidencyManager(); + var journal = new ResidencyObservationJournal(maximumEntries: 4); + ResidencyAssetKey key = + new(ResidencyDomain.PreparedMeshCpu, 4); + AssetHandle first = manager.Request( + key, + ResidencyPriority.Far, + worldGeneration: 1, + frame: 0); + Assert.True(journal.TryPublish(new ResidencyObservation( + first.Untyped, + ResidencyObservationKind.Transition, + AssetResidencyState.Prepared, + new ResidencyCharges(CpuPreparedBytes: 10), + ResidencyPriority.Far, + Frame: 1))); + Assert.True(journal.TryPublish(new ResidencyObservation( + first.Untyped, + ResidencyObservationKind.Transition, + AssetResidencyState.Cancelled, + ResidencyCharges.Zero, + ResidencyPriority.Far, + Frame: 2))); + Assert.Equal(1, journal.Count); + Assert.Equal(1, manager.Drain(journal)); + + AssetHandle replacement = manager.Request( + key, + ResidencyPriority.Visible, + worldGeneration: 2, + frame: 3); + Assert.True(journal.TryPublish(new ResidencyObservation( + first.Untyped, + ResidencyObservationKind.Transition, + AssetResidencyState.Prepared, + new ResidencyCharges(CpuPreparedBytes: 10), + ResidencyPriority.Far, + Frame: 4))); + + Assert.Equal(0, manager.Drain(journal)); + Assert.True(manager.TryGetSnapshot(replacement, out var snapshot)); + Assert.Equal(AssetResidencyState.Requested, snapshot.State); + } + + [Fact] + public void JournalHasExplicitBoundForDistinctObservations() + { + var manager = new ResidencyManager(); + var journal = new ResidencyObservationJournal(maximumEntries: 1); + AssetHandle first = manager.Request( + new ResidencyAssetKey(ResidencyDomain.PreparedMeshCpu, 1), + ResidencyPriority.Far, + worldGeneration: 1, + frame: 0); + AssetHandle second = manager.Request( + new ResidencyAssetKey(ResidencyDomain.StandaloneTextures, 2), + ResidencyPriority.Far, + worldGeneration: 1, + frame: 0); + + Assert.True(journal.TryPublish(Touch(first, frame: 1))); + Assert.False(journal.TryPublish(Touch(second, frame: 1))); + Assert.Equal(1, journal.Count); + } + + private static AssetHandle Resident( + ResidencyManager manager, + ulong id, + ResidencyPriority priority, + long frame, + long bytes, + long rebuild) + { + AssetHandle asset = manager.Request( + new ResidencyAssetKey(ResidencyDomain.ObjectMeshes, id), + priority, + worldGeneration: 1, + frame, + rebuild); + manager.Transition( + asset, + AssetResidencyState.Prepared, + new ResidencyCharges(CpuPreparedBytes: bytes), + frame); + manager.Transition( + asset, + AssetResidencyState.UploadPending, + new ResidencyCharges( + StagingBytes: bytes, + GpuRequestedBytes: bytes), + frame); + manager.Transition( + asset, + AssetResidencyState.Resident, + new ResidencyCharges(GpuResidentBytes: bytes), + frame); + return asset; + } + + private static ResidencyObservation Touch( + AssetHandle asset, + long frame) => + new( + asset.Untyped, + ResidencyObservationKind.Touch, + AssetResidencyState.Absent, + ResidencyCharges.Zero, + ResidencyPriority.Visible, + frame); +} diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index 83245e35..d91055da 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using AcDream.App; +using AcDream.App.Rendering.Residency; namespace AcDream.App.Tests; @@ -11,6 +12,61 @@ namespace AcDream.App.Tests; /// public sealed class RuntimeOptionsTests { + [Fact] + public void ResidencyBudgetsPreserveCurrentProductionDefaults() + { + RuntimeOptions options = RuntimeOptions.Parse( + "D:\\dat", + _ => null); + + Assert.Equal(ResidencyBudgetOptions.Default, options.ResidencyBudgets); + } + + [Fact] + public void ResidencyBudgetOverridesAreTypedMebibytesAndCounts() + { + var values = new Dictionary + { + ["ACDREAM_RESIDENCY_MESH_GPU_MIB"] = "768", + ["ACDREAM_RESIDENCY_MESH_UNOWNED_ENTRIES"] = "72", + ["ACDREAM_RESIDENCY_ANIMATION_MIB"] = "48", + ["ACDREAM_RESIDENCY_ANIMATION_ENTRIES"] = "300", + }; + + RuntimeOptions options = RuntimeOptions.Parse( + "D:\\dat", + name => values.GetValueOrDefault(name)); + + Assert.Equal( + 768 * ResidencyBudgetOptions.MiB, + options.ResidencyBudgets.ObjectMeshGpuBytes); + Assert.Equal( + 72, + options.ResidencyBudgets.ObjectMeshUnownedEntries); + Assert.Equal( + 48 * ResidencyBudgetOptions.MiB, + options.ResidencyBudgets.AnimationBytes); + Assert.Equal(300, options.ResidencyBudgets.AnimationEntries); + } + + [Theory] + [InlineData("0")] + [InlineData("-1")] + [InlineData("bad")] + [InlineData("8796093022208")] + public void InvalidResidencyBudgetFallsBackToCurrentDefault(string value) + { + RuntimeOptions options = RuntimeOptions.Parse( + "D:\\dat", + name => name == "ACDREAM_RESIDENCY_MESH_GPU_MIB" + ? value + : null); + + Assert.Equal( + ResidencyBudgetOptions.Default.ObjectMeshGpuBytes, + options.ResidencyBudgets.ObjectMeshGpuBytes); + } + private const string AnyDatDir = "C:/Users/test/dats"; private static Func Env(Dictionary values)