feat(render): establish typed residency policy ledger

Define Slice D's transition and concurrency contract, add generation-safe asset handles and owner leases, and parse the existing cache ceilings through typed runtime budgets. Stale generations cannot release or revive replacements, and worker observations are bounded and coalesced.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-24 16:05:46 +02:00
parent a564c4b782
commit 1866ea0c6d
8 changed files with 1696 additions and 2 deletions

View file

@ -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<TAsset>(uint Index, ushort Generation);
readonly record struct OwnerToken(uint Index, ushort Generation);
readonly record struct AssetLease<TAsset>(
AssetHandle<TAsset> 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.