# Asset residency

How shared assets stay in memory, who holds them, and what happens at free-at-zero.

**Code:** `asset_store.zig`, `material_cache.zig`, `residency_scope.zig`, scene loaders, `audio/audio.zig`, editor Residency panel.  
**Related:** [Assets and Shinra](/docs/systems/assets-and-shinra.md) · [Refs](/docs/systems/refs.md) · [Session services](/docs/systems/session-services.md) · [Rendering](/docs/systems/rendering.md) · [Renderer architecture](/docs/systems/renderer-architecture.md)

**Contents:** [Mental model](/docs/systems/asset-residency.md#mental-model) · [Tracked layers](/docs/systems/asset-residency.md#what-is-tracked-where) · [Consumers](/docs/systems/asset-residency.md#who-retains-what-consumers) · [Free at zero](/docs/systems/asset-residency.md#what-happens-at-refs--0) · [Budget](/docs/systems/asset-residency.md#residency-budget-keep-alive-tier) · [Residency pass](/docs/systems/asset-residency.md#the-residency-pass-stage-table) · [Texture / mip streaming](/docs/systems/asset-residency.md#gpu-texture-and-mip-streaming) · [Generated / GPU geometry](/docs/systems/asset-residency.md#generated--gpu-geometry) · [Scene / stream](/docs/systems/asset-residency.md#scene-transitions-and-streaming) · [Diagnostics](/docs/systems/asset-residency.md#diagnostics) · [Invariants](/docs/systems/asset-residency.md#invariants-tests--mental-checklist)

---

## Mental model

Shared decode lives in **`AssetStore`** (spin mutex on public entry points). Each use **retains**; each end **releases**. At `refs == 0` and unpinned, the CPU payload is destroyed **immediately**, unless a [keep-alive budget](/docs/systems/asset-residency.md#residency-budget-keep-alive-tier) is configured (product default **512 MB**; zero = debug “re-decode every acquire”).

```
Entity (unique: components, vertex buffer copies, scripts, physics bodies)
    │ despawn → free unique state always
    │
    ├─ MaterialCache ──retain textures──► AssetStore (CPU: Texture, Model, EngineMesh, AudioCue, Font, Blob)
    ├─ RenderPrimitive ─retain mesh─────►
    ├─ Skybox / probe ──retain texture──►
    └─ Audio worker ────retain cue──────►  (+ separate decoded PCM map, voice-safe)

Stream cell / preload ── ResidencyScope ─► same AssetStore counters
Scene replace bridge ── beginReplacePreload ─► same
```

The store **does not know** about scenes, layers, or stream cells. Only **counts** matter. Any number of concurrent consumers (layers, cells, editor previews) share one counter per path.

**Keys** for typed decoded caches are extension-free stems. Typed blob and dependency-graph identities include the cooked suffix, so different payload kinds at the same stem stay distinct.

---

## What is tracked where

### Layer 1 — `AssetStore` (shared CPU decode)

| Kind | Payload | API |
|------|---------|-----|
| **Texture** | Decoded image (+ optional GPU handle) | `retainTexture` / `releaseTexture` / `hasTexture` |
| **Model** | Cooked model parse | `retainModel` / `releaseModel` / `hasModel` |
| **Engine mesh** | Dual payload: rigid indexed 32 B verts + `u32` indices **or** skinned indexed 40 B verts + `u32` indices | `retainEngineMesh` / `releaseEngineMesh` / `hasEngineMesh` |
| **Audio cue** | Cooked audio bytes | `retainAudioCue` / `releaseAudioCue` / `hasAudioCue` |
| **Font** | Parsed TrueType | `retainFont` / `releaseFont` / `hasFont` |
| **Blob** | Opaque bytes (scripts, scene JSON, collision, materials, particles, animgraphs, …) | `retainBlob` / `retainBlobKind` / `releaseBlob` / `hasBlob` / `retainPinnedBlob` |
| **Shader artifact** | Platform blob for a package role | `retainShader` / `releaseShader`; renderer uses scoped `ShaderAssetSource.Lease` |

There are **no** `getTexture` / `getBlob` / similar aliases. Names that load always mean **retain** (or **retain + pin**). The `has*` probes return **bool, never a borrowed pointer** — anything that needs the payload must retain it.

### Concurrency contract

The lock is held for **map operations only — never across IO or decode**. Two threads
may therefore decode the same path concurrently; the loser destroys its copy and adopts
the winner's (`retainResident` step 3). Duplicate decode costs one asset's work; holding
the mutex across a pack read plus zstd inflate would stall every other thread — including
the audio worker — for the full duration. Every residency kind shares one implementation
(`asset_store/residency.zig`) so the three lock regions and the lost-race branch cannot
drift apart per kind.

Each active decode registers a stack-owned receipt under the store mutex. Reload
invalidates matching stem/family receipts, including assets not yet resident. An
invalidated decode destroys its result and returns `AssetNotReady`; a stale failure
cannot overwrite the new asset state. Receipts disappear when the decode returns.

The mutex spins briefly and then yields, so a **preempted** lock holder cannot make
every waiter burn a core.

Each resident entry has:

| Field | Meaning |
|-------|---------|
| **`refs`** | Number of explicit retains (materials, meshes, scopes, short-lived parses, …) |
| **`pin`** | Explicit session-lifetime holds (`pinPath` / `retainPinnedBlob`). Survives `refs == 0` until unpinned or invalidated by hot reload; reserved for intentional reuse, including engine shader packages and parsed material documents |

**Pack IO** is separate: `retainBundle` / `releaseBundle` open/close pack catalogs. **`releaseBundle` does not mass-evict decoded assets.** Uncompressed pack payloads stay zero-copy through an atomic mapping lease held by the decoded entry; closing the catalog prevents new reads but unmaps only after the last decoded lease is destroyed. Compressed payloads remain independently owned after inflate. In editor/loose mode, large immutable cooked textures, models, and collision payloads use the same lease contract over individual read-only file mappings instead of anonymous whole-file copies. The mapping does not keep the source descriptor open and remains valid across Shinra's atomic replacement; clean pages are reclaimable by the OS. Decoded free is only via asset `refs`/`pin`.

### Layer 2 — `MaterialCache` (shared materials)

| Item | Tracking |
|------|----------|
| Material template / MI | Path-keyed **refcount** (`retain` / `release`) |
| Textures on a material | Store `retainTexture` only for **successfully bound** slots (slot non-null ⟺ retain held); rebind retains new before releasing old |
| Layer / scene lists | `world.material_paths` + per-layer lists — one retain per unique key for that load |

Materials are **not** immortal across scenes: unload releases that layer’s keys; last global release destroys the material entry and its texture retains.

### Layer 3 — Entity-unique (not in AssetStore)

| Item | Lifetime |
|------|----------|
| Components, scripts, user_data | Despawn / unload |
| `VertexBuffer` CPU mirror on prim | Rebind / prim deinit |
| `ProceduralMesh` vertex store (SDK) | Owner's `deinit` |
| Physics bodies | Entity release → deferred physics free |
| Script VM instances | Entity script destroy |

### Layer 4 — GPU (render thread)

| Item | Tracking |
|------|----------|
| Geometry / material / pipeline handles | `RenderResidency` refcounts |
| Sampled texture GPU objects | Created when used; freed via `pending_texture_frees` → `release_texture` when **CPU** texture last-ref frees |
| Vertex/index GPU buffers | Released when no primitive uses that geometry key |

GPU free is **frame-deferred** (in-flight draws). That is independent of CPU refcounts, but CPU texture free is what enqueues texture GPU free. Deferred records are idempotent. If their queue cannot grow, the resource-table slot carries an allocation-free retirement marker and the normal frame sweep reclaims it later; allocation pressure never changes a deferred free into an unsafe immediate free.

`register` rolls back **every** retain if any step fails, including the final
`primitives.put` — an OOM there must not strand a refcount on a material or
pipeline.

`rebindMapsForTexture` consults a texture-to-primitive reverse index rather than
scanning every resident primitive per completed upload. The index includes ready
textures because mip transitions replace their GPU handles after initial
publication; every user must move before the old handle retires. Live-cook
material mutations refresh the indexed texture snapshot even when the Material
pointer stays unchanged. If the index cannot be maintained, overflow forces an
exhaustive scan and rebuild — degradation is performance, never correctness.

### Layer 5 — Audio worker (decoded PCM)

| Item | Tracking |
|------|----------|
| Cooked cue in store | `retainAudioCue` while a decoded entry exists |
| Decoded stereo PCM | `AudioSystem.decoded` map; held while **any voice** still samples that buffer |
| Free | Worker `sweepIdleDecoded` when mixer marks sweep needed (stop / stop_all / one-shot end): no voice → free PCM + `releaseAudioCue` |

Device mix thread may null a finished one-shot’s sample pointer and set a sweep flag; the **worker** reclaims the PCM (never free from the device callback).

---

## Who retains what (consumers)

| Consumer | Holds | Releases when |
|----------|-------|---------------|
| **Layer `material_paths`** | Materials (→ textures) | Layer unload / full unload |
| **MaterialCache entry** | Textures in desc | Material refcount → 0 |
| **Bound `RenderPrimitive`** | Engine mesh path | Rebind, error mesh, procedural `setMesh`, entity despawn |
| **Skybox** | Cubemap texture | Clear / replace path / world destroy |
| **Audio play path** | Cue + decoded PCM | No voice uses samples |
| **Short parse** (model_doc, scene, collision, input_actions, script bytecode) | Blob for the call | `defer releaseBlob` after parse / `loadBytecode` |
| **Material document parse** (`loadDesc`) | Scoped blob retain plus a store pin | Retain released after parsing; pin reused until hot reload or store teardown |
| **Engine shaders** | Scoped blob retain plus a store pin for package reuse | Acquire releases its retain after native copy/compile; hot reload clears the pin, and store teardown reclaims remaining pins |
| **content://** | Blob | Scoped host retain while the SDK copies into caller-owned memory; released before `Content.read` returns |
| **Kawa `Content.read`** | Blob briefly | After copy into Kawa string |
| **`beginReplacePreload`** | Materials + store scope (meshes + visual-zone grading textures) for incoming scene | After replace load completes (`SceneResidencyPreload.deinit`) |
| **`ResidencyScope`** | Whatever the scope retained (also used inside scene preload for store assets) | `releaseAll` / `deinit` (stream cell exit / preload bridge drop) |
| **Editor inspector / texture drop** | Temporary retain | End of inspect / drop prepare |

---

## What happens at `refs == 0`

| Resource | Unpinned `refs → 0` | Pinned |
|----------|---------------------|--------|
| **Texture (CPU)** | Destroy immediately, or park when keep-alive is configured; GPU handle always frees deferred | Stays until last `unpinPath` |
| **Model** | Destroy immediately, or park when keep-alive is configured | Stays while pinned |
| **Engine mesh soup** | Free immediately, or park when keep-alive is configured | Stays while pinned |
| **Audio cue (store)** | Destroy immediately, or park when keep-alive is configured (after decoded PCM drops its retain) | Stays while pinned |
| **Font** | Destroy immediately, or park when keep-alive is configured | Stays while pinned |
| **Blob** | Free immediately, or park when keep-alive is configured | Stays only for an explicit long-lived pin |
| **MaterialCache entry** | **Immediate** destroy material + shader (if owned) + **release textures** | N/A (no pin map; use store pin for textures) |
| **Decoded audio PCM** | **Immediate** free when no voice samples it (worker sweep) | N/A |
| **GPU geometry / pipelines** | When **RenderResidency** count hits 0 → deferred free | N/A |
| **GPU texture** | After CPU texture free enqueues handle → render thread deferred free | N/A |

There is **no mark-and-sweep GC** and **no “wait N seconds then free”** for real memory. The editor “Recently freed” list is a **diagnostic ring only** (last 48 frees).

Store-side ownership-transfer queues intentionally panic if they cannot record a
texture free or in-flight upload. Renderer deferred-free tables mark the slot
orphaned when their queue is out of memory, so a bookkeeping failure never becomes
an unsafe immediate GPU free.

**Special paths**

- **Hot-reload force-evict (family + stem):** `forceEvictFamily` in `scene_ops` — (1) abandon world holders for that family only (texture → skybox/LUTs/**material map slots**; model → `bound_mesh` + animation skeleton/clip binds; blob → mark animation-graph actors stale), (2) `evictFamilyForReload(stem, family)`. Abandon **releases** every hold rather than nulling it — eviction defers to the last borrower, so a hold dropped without its release strands the entry `reload_pending`. Material slots must still be released *before* `evictFamilyForReload`, or a following `refreshTextureAsset` retain-new + release-old on the same path destroys the fresh decode. Same stem + different kind is valid: a `.shintexture` event must not wipe a model/audio/font/blob at that stem. Model companions (`.shinmodel` / `.shinmodeldoc` / `.shincollision`) share the **model** family and one wipe. `ReloadFamily` is `texture` / `model` / `audio` / `font` / `blob`. **Batch-deduped** per `(family, stem)`. Never call store force-evict without abandon first. Pin clear applies to model/font/blob families. Blobs with active scoped readers become `reload_pending`: new acquisitions stop, the old slice remains valid, and the last scoped release destroys it before a fresh version may load.
- **Last-ref destroy** (`destroy*Entry`): debug asserts `refs == 0`. Force-evict never bypasses live refs: a still-borrowed entry is marked `reload_pending` and destroyed by its last release; it never invalidates a live blob lease.
- **`AssetStore.deinit` / session teardown:** `logLeaksIfAny` warns on **unpinned** leftovers, then clears everything.

---

## Residency budget (keep-alive tier)

**Code:** `assets/asset_store/budget.zig`.

Without a budget there is nothing to evict: an asset is either referenced (must
stay) or at `refs == 0` (already gone). A budget only means something once
unreferenced assets are allowed to linger.

| State | Meaning |
|-------|---------|
| **hot** | `refs > 0`. Never parked, never evicted. |
| **cold** | `refs == 0`, still decoded, counted against the budget, evictable oldest-first. |
| **pinned** | Outranks the budget entirely. Never parked, never evicted. |

```zig
store.setBudget(.{ .keep_alive_bytes = 512 * 1024 * 1024 });
store.trimToBudget();              // also runs automatically on release
const stats = store.budgetStats(); // cold_bytes / cold_entries / evicted / resurrected
store.setBudget(.{});              // hard flush of all unreferenced residency
```

Standalone sessions configure the same budget in `configs/game.json`; JSON wins
over the game module default. The texture budget already defaults to 768 MB, so
the texture entry is only needed to override it:

```json
{
  "residency": {
    "keep_alive_mb": 512,
    "gpu_geometry_mb": 1024,
    "geometry_submission_stream_mb": 144,
    "ray_tracing_mb": 512,
    "texture": { "gpu_budget_mb": 768 }
  }
}
```

| Field | Layer | Zero means |
|-------|-------|------------|
| `keep_alive_mb` | CPU decoded assets | Immediate destroy at `refs == 0` |
| `gpu_geometry_mb` | Live GPU mesh geometry (shared keys + dynamic rings) | Unlimited; new uploads always accepted |
| `geometry_submission_stream_mb` | Soft camera triangle-stream target, per stream per frame/surface slot | No budget-driven LOD reduction |
| `ray_tracing_mb` | Soft BLAS/TLAS, scratch and compaction-source target | Only normal age-based BLAS cache eviction |
| `texture.gpu_budget_mb` | GPU bytes for ordinary 2D material-map mip chains | Mip streaming disabled; full authored chains |

Texture mip streaming is enabled by default with a conservative 768 MB budget.
The remaining policy defaults are a four-mip tail, zero LOD bias, 30 frames of
eviction grace, and at most 16 replacement requests per published frame.

`gpu_geometry_mb` is a **hard ceiling on new mesh uploads**. Joining an already
resident geometry key costs nothing. Growing a live dynamic mesh is always
allowed. New keys that would exceed the ceiling fail the create. Tracked as
`RenderResidency.geometry_live_bytes` (exact).

Camera submission pressure reduces individual objects by projected size while preserving complete geometry. RT opaque receivers share the exact selected LOD with raster. Submission allocation accounting includes both early/late streams and all frame/surface slots; the per-stream target is not a device-wide ceiling. RT pressure evicts unused BLAS early but retains active and in-flight structures. These startup settings require a renderer restart. See [geometry capacity](/docs/systems/rendering.md#compute-driven-meshlet-submission) for accounting and diagnostics.

**Policy stays out of the store.** A streamer sets the budget and calls
`trimToBudget`; it never touches the maps. Release stays O(1) when under budget.
If parking cannot be recorded (allocation failure) the entry is destroyed — a
bookkeeping failure must never become a memory leak. Eviction advances a queue
cursor and compacts only after enough records have been consumed or invalidated;
steady eviction does not shift the remaining queue on every release.

Async mip completion can change a parked texture's CPU size. Publishing the new
suffix updates `cold_bytes` under the store lock and immediately trims the budget.
The worker hold keeps an evicted texture alive until publication finishes.

Owned buffers charge allocation size. A leased zero-copy pack or loose-file slice is charged by
logical payload size so a cache of mappings cannot bypass the ceiling. Every
destroy route settles through `noteEntryDestroyed`. Parked entries report as
`cold_entries` in `LeakReport`, **not** as leaks.

For chunk scopes, call `ResidencyScope.reserve(entry_count, path_bytes)` from the
chunk manifest before the first enter. The scope uses two retained-capacity
linear vectors so steady-state enter/exit has no per-path heap allocation.

---

## GPU texture and mip streaming

Whole-texture streaming and mip streaming are two layers of the same residency
story, but they do not share a lifetime:

1. A scene, material, preload, or future world cell retains a **texture asset**
   through `AssetStore`. This controls metadata and selected CPU-payload lifetime
   and whether any GPU texture may exist.
2. Visible material geometry submits projected screen coverage. The estimate
   uses the sphere's near camera extent, so a large floor or wall that reaches
   the camera still requests detail even when its bounds centre is far away.
   Demands for a shared texture combine by highest required detail and priority.
3. The store arbitrates those demands against one session-wide GPU texture
   budget, with a guaranteed small-mip tail, a demotion grace period, and a
   per-frame transition ceiling.
4. A dispatch worker range-reads the selected cooked mip suffix. Loose assets use
   positional file reads; raw texture entries in packs use a leased mmap slice.
5. The render thread uploads that suffix as a smaller physical texture.
   Completion atomically swaps the published handle; the old image remains
   sample-safe until then and is retired through the normal frame-deferred free
   path. Once safe, the previous CPU suffix is released too.

The implementation deliberately does not require sparse/tiled-resource support:
Metal and D3D12 use the same replacement contract. A physical mip 0 can therefore
represent authored mip N; its dimensions and uploaded chain are shifted together,
so ordinary normalized-UV sampling remains correct. During a replacement both
images can exist briefly. The configured budget and Residency-panel reading are
steady-state bytes; the transition count plus the renderer's measured create
budget bound temporary overlap and upload work.

Only ordinary 2D **material maps** participate today. Cubemaps and consumers
without a projected-size signal (UI, sky/probes, LUTs, decals, and tools) request
the full chain. Editor material, model, and animation previews are tool consumers,
so their material maps also request full detail. If a map is shared with one of
those consumers, the full-resolution request wins for that CPU residency lifetime.
This avoids using a material-only heuristic to degrade a different contract.
Material and particle previews submit full-detail requests through
`AssetStore.requestTextureGpuUpload`, which wakes CPU mip reads even while the
product World skips static publication. GPU upload completion then triggers the
preview's material rebind and redraw.

Coverage demand includes all nine material-map slots, including anisotropy and
clearcoat. Retained opaque and transparent draw rows refresh their streaming
watches on material registration or texture-binding changes, so textures that
bind after scene publication still request detail in Edit mode. A dedicated
cache signal coalesces those changes; parameter edits and assignments of the
same texture keep the watches intact. This refresh preserves draw structure
and removes watches when their last streamed map is unbound; geometry LOD
watches remain active independently.

The fixed header and complete mip directory remain CPU-resident, but pixel payload
does not: initial decode reads only metadata plus the configured tail, and each
transition replaces that payload with the demanded suffix. This keeps the demand,
asset ownership, and GPU replacement model shared while bounding both decoded CPU
payload and GPU residency. Shinra stores `.shintexture` entries uncompressed even
when a bundle's default is Zstd, because the texture payload is already
GPU-block-compressed and must remain range-addressable; other entries still use
the requested bundle compression.

### Open-world integration seam

World streaming should not build a second texture cache. A cell owns its normal
`ResidencyScope` for asset lifetime and submits conservative, low-priority mip
demands to the same texture demand accumulator while preloading. Once visible,
renderer coverage naturally wins. Cell exit releases the asset scope; if another
cell or material shares the texture its refcount and strongest demand keep it
alive. The grace window absorbs camera and cell-boundary oscillation.

When a high-detail demand arrives while the budget is full, the store can first
demote an older, non-demanded texture in the same pump. If the complete request
does not fit, it admits the best finer suffix that does fit. Further upgrades
require more headroom. This prevents a nearby surface from remaining at its guaranteed
tail merely because one large upgrade could not fit in one budget step.

The coverage estimate remains conservative; it does not measure shader UV
derivatives. A visible working set or full-resolution pins larger than the budget
can still leave nearby surfaces below their requested detail. Grace-protected
reclamation retries publish fresh demand even with a stationary camera; a fully
budget-blocked request alone does not force continuous idle publication.

The `texture_streaming` warning scope reports budget-limited requests (including
partial upgrades), missing detail bytes, accounted/budgeted bytes, pending
transitions, and an example cooked texture path with requested/affordable mip
levels. Mip 0 means full detail. Warnings are limited to once every five seconds
per store while arbitration runs, including intermittent pressure. A request
deferred solely by the per-publish transition limit is not counted as budget
limited. These are configured texture-budget diagnostics, not device-wide VRAM
pressure or allocation-failure measurements.

Once a range-streamed texture reaches its uploaded CPU mip base, the next store
pump releases its CPU mip backing under the store lock. It reserves the upload
slot while clearing slices and updates cold-cache accounting before publishing
statistics. Immutable mip layouts and the source lease remain for later range
reads. GPU completion wakes this reclamation even when world publication is idle.
Non-streamed textures retain their original CPU payload contract.

The editor **Residency → GPU** page reports current/budgeted GPU texture bytes,
current CPU mip-payload bytes, resident and full-detail counts, pending
transitions, and remaining budget overflow. Overflow can be temporary while
transitions are pending or capped; persistent overflow can indicate the budget
is smaller than the guaranteed tails and full-resolution pins. Streaming stats
also expose the evaluated budget-limited request count and missing detail bytes.

---

## Generated / GPU geometry

Generated meshes (chunks, trails, splines) **never enter `AssetStore`**. They live
on the primitive `VertexBuffer` and are shared on the GPU by **geometry key**,
refcounted in `RenderResidency.geometry_refs`.

| Keying | When |
|--------|------|
| **Content hash** (default) | Authored / one-off meshes — identical meshes share one GPU buffer |
| **Identity** (`geometry_id`) | Generated geometry rebuilt often — O(1), assumed unique |

SDK: `ProceduralMesh` (`sdk/src/procedural_mesh.zig`) for render-only generated
meshes; `RuntimeGeometry` when the same shape also needs a triangle collider.
Identity does not stick to the actor — any vertex-data replacement clears
`geometry_id`. Dynamic non-indexed meshes can rewrite in place
(`update_primitive_geometry`); indexed / static updates retire and re-create.

Apply of create commands is budgeted on the render thread (`RenderCommandQueue.takeBudgeted`) **by measured cost, not by count**: a create that joins a resident geometry key costs microseconds while the first primitive of a new material compiles a pipeline (milliseconds). `noteApplyCost` folds each frame's measured create time into an EMA and derives the next frame's count against the residency urgency's `targetNs`, floored at one and ceilinged by `ApplyBudget.create_ceiling`. What counts is `createsGpuObjects`, not the command name: `applyUpdatePrimitiveMaterial` compiles pipelines exactly as a create does and is budgeted the same way.

Asset binding keeps metadata per primitive and retains shared decoded geometry
in the store until successful render completion. The first admitted instance stages the
vertices, indices and meshlets; other instances join the pending or resident
geometry key without copying the payload. The pending identity survives queue
draining until the renderer completes or abandons all queued users. Completion
receipts include the primitive request serial, so an obsolete attempt cannot
release a newer binding. Missing geometry re-enters the ordinary rebind path.
Backend retries retain the shared decode. Geometry-budget refusals release the
upload-only retain and wait for capacity or content changes; queued followers
inherit the same refusal. Results are indexed once and handled in one scene walk.
`Allow CPU Access` keeps the store retain for the full binding lifetime.

Queue-owned CPU geometry is also admitted by bytes, including meshlet topology
and triangle-count metadata: retry-capable world and
swarm publishers use a 256 MiB window, restore ownership on
`UploadBackpressure`, and remain dirty until the render thread drains enough
payload to admit them. Dynamic vertex/descriptor updates use the same byte
window and preserve dirty data when refused. Draining retains a head cursor and
compacts geometrically; cached create and urgency counts avoid rescanning the
backlog. One oversized mesh may enter an empty queue so valid
content cannot deadlock. One-shot editor previews use the unbounded create API;
they are still measured, but flow control can never silently turn a preview or
tool action into a permanently missing resource.

The game-thread deferred texture pump (`pumpDeferredTextureResidencyLocked`) runs
only when `texture.residency_epoch` moved **and** watchers exist.

On D3D12, completion of the texture upload fence releases both the staging
resource and its upload command list/allocator; the sampled texture does not
keep those temporary objects for its resident lifetime.

Draw / upload details: [Rendering](/docs/systems/rendering.md), [Renderer architecture](/docs/systems/renderer-architecture.md).

---

## Residency tickets (readiness as an event)

An asset becoming resident is an **event delivered to the things waiting for it**, not a state change every subsystem discovers by rescanning itself (a scalar generation counter made every consumer re-walk everything it owned on every cook edge).

```
open → require × N → seal → (mailbox edge) → close
```

- **The waiter key is `(stem, kind)`.** Keying on the stem alone opens a gate early: a material landing at a model's stem would satisfy the model's waiter. Highest-risk detail in the whole design.
- **`seal` is not optional.** An unsealed ticket never satisfies — it stays open for requirements that may still arrive.
- **No consumer code runs on decode threads.** A decode completing writes to a mailbox; the session tick drains it. `drainResidencyWakes` returns the `(stem, kind)` list that woke each ticket, so a consumer binds what landed instead of re-examining everything it owns.
- **Handles are revalidated at wake.** Slot + generation; a stale waiter drops silently. Never store a dense index in a ticket or a waiter.
- **`WakeMode`** is the presentation policy in disguise: `.progress` wakes per asset (incremental binding, the editor's pop-in), `.complete` wakes once when the set is done (gates).
- **A failed asset satisfies, degraded.** `failed > 0` opens the gate and reports; it never hangs a level on a bad cook.
- **Hot-reload force-evict must cancel and reopen.** A retired stem stops answering acquires (`reload_pending` → `AssetNotReady`) until the new cook lands, so a ticket holding an evicted stem has to re-arm rather than report satisfied.

Owners: a `SceneLoadJob` owns one ticket for everything its document names; the world owns one for its own assets (skybox, zones, decals, particles); `hi.assets()` hands game code its own. Live tickets and their unresolved requirements are visible in the editor's **Residency** panel.

## The residency pass (stage table)

**Code:** `scene/world/world_residency.zig`, `scene/residency_budget.zig`, `scene/residency_filter.zig`.

Every readiness edge (store attach, bundle retain, hot reload, scene load, ticket wake, budgeted resume) runs the same pass over seven consumers. Each consumer is one row in `stages`, a `std.EnumArray` over `StageId`, so a new consumer that forgets its row is a compile error rather than a consumer that silently never runs, and the min-progress rule, kind gate, prefetch handover and debt flag are applied by the driver, not re-spelled per consumer.

| Stage | Wakes on | Decodes |
|---|---|---|
| `skybox` | `texture` | |
| `visual_zone` | `texture` | |
| `decal` | `texture` | |
| `particle` | `particle`, `texture` | |
| `material_upgrade` | `texture`, `material` | yes |
| `fog_volume` | `material` | |
| `geometry` | `model`, `model_doc`, `animation_graph`, `script` | yes |

The row declares *what can make this consumer productive* and *whether it decodes on the game thread*. The driver — not the row — applies the budget, the filter and the warm-up gate.

### The cursor

`World.residency_stage_cursor` is where the next slice enters the table. A pass that runs out of time parks the cursor on the stage it stopped in; the next pass starts there and rotates on. One turn of the table visits every stage exactly once from any cursor, so **no stage can starve behind another**, whatever the ones ahead of it cost. That property is a unit test on `stageAt` alone — it needs no world and no assets.

Rotation is legal because the stages have no ordering dependency. A primitive that takes a stand-in material before `material_upgrade` runs is patched by `replaceMaterialReferences` when it does; that is the same path a mid-cook upgrade already used.

### Debt

`World.residency_work_pending` means *some consumer, not named by any wake, is still owed a look*. It is written in exactly one place — the pass epilogue — from what the pass actually managed to cover, plus two external arms between passes: a runtime spawn whose mesh could not bind, and a scene script recorded before its bytecode landed.

- Only a pass that **covered every consumer and reached the end of every stage** can discharge it. A filtered wake examines the assets that wake named and nothing else, so reaching its end proves nothing about debt left by something else — it carries the standing debt forward.
- A wake pass with slice left over **promotes itself** to a full pass and pays that debt on the same budget, rather than leaving it for a frame with no wake in it. During a cold cook wakes are continuous, so that frame may not arrive for seconds.
- The session tick spends **one slice per frame** (`residencyPassRanThisFrame`). The resume in `tickPublish` is deliberately not an `else` of the wake branch: a satisfied ticket is not necessarily *this world's* — during a scene load the job pool holds its own — so gating the resume on "no wake this frame" meant that during a cook the debt never drained at all.

### The stall watch

A residency loop that neither progresses nor terminates is invisible by construction: the world simply never finishes loading, with nothing in the log. `noteStall` counts consecutive passes that owed work, committed nothing, dispatched nothing, waited on no decode batch **and** held no open store ticket — each exclusion is a legitimate reason to make no progress, and what is left is not. After ~4 s of such frames it warns once, naming the stage the cursor is parked on, then repeats sparsely.

### The budget knob — off by default, and why

`game.world.residency_bind_budget_ms` (default **0**, editor: *Project Settings → World → Scene streaming*) sets `World.residency_bind_budget_ns`. **`0` means no ceiling**: a pass binds everything that has become bindable.

Off by default because the work a ceiling would bound is already cheap: `residency_prefetch` moved decodes to the worker pool, so what reaches the bind pass is normally a store hit and metadata binding, and a fixed slice cannot tell that apart from an inline decode. Geometry staging is separately admitted at render publication; a 2 ms ceiling would spread that over seconds of visibly half-built scene. The rotation, min-progress rule, debt ledger and stall watch exist to make a **non-zero value safe**, not mandatory. Set a ceiling if you measure a residency hitch; 2 ms is the value to try first.

## Decode is off the game thread

**A bind must never decode.** Every `retain*` on the store decodes on the calling thread the first time, and the frame budget cannot slice one — `residency_bind_budget_ns` is checked *between* units of work, never inside one, because a half-bound asset is not a resumable state. One Sponza-sized mesh runs to completion whatever the budget says.

### The unit of work is a primitive, not an actor

A `model` reference expands into one primitive per mesh part on a **single** RenderComponent, so a budget checked between *actors* cannot split a four-hundred-part model: the whole thing lands in one frame whatever the value. The budget is checked between primitives.

A part-bound actor *is* resumable, which is what makes the finer check legal where stopping inside a single `applyBundleMesh` would not be: each primitive keeps its own `pending_mesh` until it binds, and `refreshSoftPending` recomputes the flag from whatever is left. Note that the partial path must still run `refreshSoftPending` / `notifyRenderDependencyDirty` — leaving the component stale hides the parts that *did* bind until some later pass happens to touch the same actor.

**A pass always completes at least one unit of work**, and that rule lives in `Budget`, not in its callers: `exhausted()` returns false until something has been `commit`ted, so a bare `if (budget.exhausted())` is the correct spelling everywhere. `commit` counts work, never attempts: a walk over four hundred still-pending primitives does nothing, and a forgotten `commit` costs throttling, never termination.

**`bindModelParts` counts too.** It is the *first* bind attempt for every part of a model — and for a warm cache the only one, because a part that binds there never reaches the retry path at all. Budgeting only the retry left the whole model landing in one frame again by a different route, so the same slice is threaded down from both callers: `pumpSceneInstantiate` (the spawn budget) and `rebindSoftPending` (the residency budget). The primitive *table* is still built in full — a half-sized array is not a state anything downstream can read — only the mesh binds are sliced, and parts left over are marked pending exactly as a cold part would be. `null` is for callers with no frame to protect (one-off `spawnActor`, prefab expansion, tools).

Two paths reach a bind, and both dispatch to the worker pool before it:

| Path | Dispatcher |
|---|---|
| Gated asset needed **before** scene presentation | `preload.dispatchStoreWork` |
| Immediate scene asset or asset arriving **after** spawn | `residency_prefetch.zig` |

`immediate` presentation deliberately stops preload after structural planning and stable material-stand-in creation. Actors can then instantiate with soft-pending mesh/texture bindings and upgradeable materials; finalize wakes the world residency pass, whose bounded 64-item batches reserve 75% of each batch for geometry and use the remainder for material/texture work. Unused shading capacity flows back to geometry. Gated, deadline, and manual presentation retain material parsing and the complete pre-presentation store warm-up because their contract is to withhold the layer.

Worker completion is also an ownership boundary. Every mesh/texture worker publishes an exact per-job retain receipt; adoption never infers ownership from global cache presence. If the residency scope cannot record a successful retain, the consumer releases it immediately rather than stranding a reference.

For presentation readiness, store `.ready` means only that cooked bytes exist.
A mesh still carrying `soft_pending`, or a material path still backed by an
upgradeable stand-in, continues to block a strict gate until the consumer bind
has completed. Missing/failed assets remain terminal and degrade to defaults;
`deadline` is the authored escape hatch from waiting indefinitely.

### Materials are the expensive half

A cold scene spends most of its stall in the *material* block, because `MaterialCache.upgradeErrorMaterials` clears up to `error_upgrade_batch` (8) stand-ins per pass and each one re-reads a material document and retains up to five textures, and `rebindDeferredTextures` fills every missing slot on every live material. Warming everything a pass is about to touch, not just meshes, is what removes the hitch.

### One stage, both consumers

The warm-up gate belongs to the pass, not to either consumer — whichever ran first would otherwise eat the stall alone. A stage declares `needs_warm`; the driver adopts the batch lazily at the first stage that asks, and a stage that cannot be warmed yet is *skipped* (and its work recorded as debt) rather than aborting the whole pass. Stages that decode nothing — skyboxes, particles — no longer lose a frame to a batch they were never going to read.

```
for each stage, from the cursor:
    filter names none of its kinds?     skip
    needs_warm and batch still decoding? skip, record debt
    budget exhausted?                    park the cursor here, stop
    run → done | more (batched, carry on) | yielded (park here, stop)
releaseAll                               // pass-level defer: covers every exit
submitColdDecodes → warmed something? debt
```

`submitColdDecodes` collects three ref kinds, materials first — a stand-in clearing is worth more than a mesh binding, because the mesh shows up untextured either way while the material is what makes the scene stop being pink:

| Ref kind | Collected when | Worker does |
|---|---|---|
| `.material` | entry `is_error`, document `.ready` | parse the doc off-thread, retain its textures |
| `.texture` | live material has an unbound slot | `retainTexture` |
| `.engine_mesh` | primitive soft-pending, mesh `.ready` | `retainEngineMesh` |

`.material` is the one that cannot be named in advance: the game thread does not know which textures a still-unparsed document references. The worker parses it, retains what it finds, and reports those paths back so `adopt` can own exactly those. The desc itself is thrown away — `upgradeErrorEntry` builds the real one under the cache lock, and duplicating it here would mean two owners of the same material state.

### Retain ownership

Both paths use the same sequence: worker decodes (refcount 1, unowned) → adopt into a `ResidencyScope` → the bind takes its own retain (2) → `releaseAll` (1, owned by the primitive or the material). Nothing bound it? The release drops it to zero and it is evictable — no leak, and no pin on an asset the world turned out not to need. A retain the worker cannot report is released by the worker rather than stranded.

The preload retain bridges decoding and binding; it is not a permanent pin:

```mermaid
sequenceDiagram
    participant W as Worker
    participant A as AssetStore
    participant S as ResidencyScope
    participant C as Consumer
    W->>A: Decode and retain
    A-->>W: Payload + retain
    W->>S: Adopt exact retain receipt
    opt Successful bind
        C->>A: Take its own retain
        Note over A,C: Two retains · scope + consumer
    end
    S->>A: releaseAll · drop preload retain
    alt Consumer retained
        Note over A,C: Live until consumer releases
    else No consumer
        Note over A: refs = 0<br/>Keep-alive or destruction
    end
```

### Termination

Only *cooked* assets are dispatched. One that has not cooked yet is still `.pending` in the store and its ticket wake is what brings the pass back; dispatching it would burn a worker on a guaranteed failed open, which is the polling this design removed.

`MaterialCache.Entry.warm_dispatched` closes the other end. A stand-in whose document is cooked and parseable upgrades on the very next game-thread pass, so the flag only ever stays set for one that is present and *unparseable* — and re-warming that forever is the same polling by another name. It is marked from what was actually **submitted**, never from what was collected, so a ref the batch had no room for stays eligible; a hot reload installing a fresh stand-in clears it.

**Profiling:** `mesh_bind.decode` should be ~0 in steady state. If it is not, a bind is taking the inline path it should not. `mesh_bind.stage` measures the first admitted uploader's copy per geometry key; repeated instances bind metadata without staging. `editor.scene_document.open` / `.decode` cover the authoring document, while `scene.document_load`, `scene.preload.plan`, `scene.preload.materials`, `scene.preload.store`, and the `scene.instantiate_*` zones split runtime scene-open latency; `residency.collect_cold` / `residency.prefetch_submit` show the post-presentation warm path.

## Scene transitions and streaming

### Additive layers

Load layer B while A is live → both hold materials/meshes. Unload A → only A’s retains drop. Shared paths stay if B still holds them.

### Full replace (overlap-safe)

Session replace is a `SceneLoadJob` (tick-pumped or drained by `instantiateScene`):

```
budgeted prepare of B     // A keeps simulating; gated policies also warm bulk assets
budgeted spawn of B       // live-A: A still renders; B lives in the open batch
unloadSceneForSwap(A)     // same frame as finalize; stores keep B's rows
finalize + commitJobPacks // B's packs move onto world.scene_retained_bundles
preload.deinit()          // drop bridge; steady-state = B only
```

For `immediate`, finalize also hands cold mesh/texture work to the live world's
residency prefetch; the scene job is free to retire, cancel, or make room for a
later scene operation without owning that background work.

`world_precleared` (empty world — launch, module reload, or construction of the disposable Play world) skips the live-A swap and its `onSceneWillUnload`. Editor Stop does not load a scene: it switches back to the retained editor world, then retires Play invisibly. If preload begin fails, the job instantiates without a residency bridge (shared assets may free and re-decode). World `loadScene` is the unit-test drain of the same spawn primitive.

### Stream cells

```zig
var scope = ResidencyScope.init(store);
// onEnterRange:
_ = try scope.retainEngineMesh(...);
_ = try scope.retainTexture(...);
// onExitRange:
scope.releaseAll(); // or scope.deinit()
```

Cell tables own path lists; the store only sees counts. Hysteresis lives in the streamer.

Scene-layer cells go through the scene-op pool instead — up to four load concurrently, dispatched nearest-first, with one shared frame budget. See [Session services](/docs/systems/session-services.md) for the op queue, presentation policy and cancellation rules; `hi.assets()` there is the same primitive for sets no scene describes.

### Single-asset holds: `Retained(T)`

`ResidencyScope` is for bulk lifetimes. For one asset held by one owner, prefer
`assets/retained.zig` — a move-only token binding store + key + payload:

```zig
var albedo = try RetainedTexture.retain(store, "textures/hero_albedo");
defer albedo.deinit();          // idempotent; safe alongside an early release
upload(albedo.get());

try albedo.reset(store, other); // retains new *before* releasing old
const owned = albedo.move();    // transfer; copying would double-release
```

---

## Diagnostics

Pack counts describe IO ownership, including source-pack retains held by
streamed textures for later mip reads; every resident asset and every read
retains, so only open/close transitions are logged. Packed and product-loose
packs log `pack loaded` / `pack opened` and `pack unloaded` at info. Advisory
editor packs have no bundle mapping and log `pack opened … (advisory)` /
`pack closed … (advisory)` at debug. Live counts are in `listOpenPacks` (the
Packs panel). Counts can remain nonzero during renderer shutdown until the
remaining World and store owners are destroyed; a pack still retained when the
asset IO itself is torn down is a real leak and warns `still retained at asset IO
teardown`.

In the editor, each cook batch asks the store to re-read `packs.shinplan.json`
and `assets.shingraph`. Identical bytes are a no-op: the catalog, the graph
snapshot and the bundle epoch stay as they are, so `.pending` entries are not
re-probed by batches that left the partition alone.

Diagnostics are opt-in at product build time: `--profiler-residency=cpu`,
`--profiler-residency=gpu`, or `--profiler-residency=cpu+gpu` (bare `--profiler-residency` enables both).
Omitting the flag is `--profiler-residency=off`. This does **not** disable
AssetStore/render ownership, reference counting, the keep-alive budget, eviction,
hot-reload generations, or fence-aged GPU destruction.

| Tool | Role |
|------|------|
| `checkLeaks()` → `LeakReport` | `entry_count`, `total_refs`, `pinned_paths`, `unpinned_entries`, per-kind counts |
| `logLeaksIfAny(context)` | Warn + sample paths on teardown |
| `appendLiveSnapshot` / `copyRecentFrees` / `copyTicketEntries` | Live path/kind/refs/pin; last-48 free ring; every ticket + what it waits on (`--profiler-residency=cpu`) |
| `geometry_residency.zig` | CPU mesh used vs held, static vs dynamic, staging free lists |
| `RenderResidency.geometryStats` | GPU hashed / identity / instanced / dynamic-ring bytes |
| `gpu_residency.zig` | Synchronized GPU snapshot (textures, buffers, programs, PSOs, AS) |
| Editor **Residency** tab | Right dock (`contrib` id `residency`). Overview map of CPU / GPU / Loading / History; drill-in lists refresh ~5 Hz when selected |

CPU snapshots are bounded owned copies taken under the store lock. GPU telemetry
reads authoritative render owners (resource tables / transient pool / compute
registry / RT cache) — never a second per-allocation map. The render thread
publishes one fixed-capacity snapshot; the editor copies it under a short mutex.
Counts are exact; some byte totals are estimates. Owner hints are best-effort
scans, not a per-retain owner table.

The tab opens on an **overview** (CPU assets, GPU, in-flight loads, recently freed). Kind chips jump into that class; back or Escape returns to the map. Kind pages scroll the full matching list with no display caps (the recently-freed ring of 48 is the store's own size, not a budget). Every drill-in has a name filter; chips count what the filter left, and a section hiding rows reads `12 of 843`. Each page distinguishes **not compiled** (`--profiler-residency` off) from **not bound** (no project or renderer yet). A refresh copies only the row lists the open page draws and keeps their capacity, so 5 Hz polling stops allocating after the high-water mark.

---

## Invariants (tests / mental checklist)

1. After full unload, scoped `content://` reads leave no live retain. Engine shader packages and parsed material documents may remain intentionally pinned for reuse; hot reload invalidates those pins and store teardown reclaims them.
2. Two actors, same mesh: despawn one → mesh stays; despawn both → mesh free.
3. Material release frees unique textures; shared albedo used by two materials survives the first release.
4. Additive A+B shared texture: unload A → stays; unload B → gone.
5. Replace with shared assets: preload keeps shared resident across unload (no free+reload of shared paths when preload succeeds).
6. Soft pending mesh: no mesh retain until bind succeeds.
7. Bundle IO release with live decoded refs does **not** destroy those entries.
8. `register` failing at *any* allocation leaves `material_refs` / `pipeline_refs` / `geometry_refs` empty.
9. A completed texture upload binds the same maps whether the pending index or the overflow scan runs.
10. `Retained` deinit is idempotent; a moved-from token releases nothing.
11. `states` stays bounded by resident assets — destroy paths remove the key rather than storing `.pending`.
12. A `(stem, kind)` waiter is satisfied only by that kind — a material landing at a model's stem must not satisfy the model waiter.
13. A ticket that outlives its owner decrements into a reused slot: cancel on abort, close once the edge is consumed.
14. Nothing *reacts* to `state_generation` any more. It survives as a diagnostic counter only; re-introducing a consumer re-introduces the rescan storm.
15. A probed-absent asset is not re-probed until a pack opens or closes (`probe_epoch` vs `bundle_epoch`). Absence is provisional, not cached forever.
16. One turn of the residency stage table visits every stage exactly once from any cursor. A stage cannot starve behind another however expensive the ones ahead of it are.
17. `residency_work_pending` is never cleared while a consumer is still waiting. Only a pass that covered every stage and finished it may discharge it; a filtered wake carries it forward or pays it.
18. A budget slice always completes at least one unit of work. `Budget.exhausted()` reports false until something is `commit`ted, and `commit` counts work rather than attempts.

---

## Quick code map

| Concern | File |
|---------|------|
| Store entries and public surface | `assets/asset_store/store.zig`, `assets/asset_store/types.zig` |
| Keep-alive tier, budget, eviction | `assets/asset_store/budget.zig` |
| Shared retain/release core | `assets/asset_store/residency.zig` |
| Materials + texture edges | `assets/material_cache.zig` |
| Stream-cell bulk retain | `assets/residency_scope.zig` |
| Single-asset RAII hold | `assets/retained.zig` |
| Procedural geometry + identity keying | `sdk/src/procedural_mesh.zig`, `graphics/vertex_buffer.zig` |
| Mesh bind / probe / scene preload | `scene/scene_loader.zig`, `render_component.zig` |
| Residency pass, stage table, debt | `scene/world/world_residency.zig` |
| Frame slice + min-progress rule | `scene/residency_budget.zig` |
| What a wake delivered | `scene/residency_filter.zig` |
| Scene replace wiring | `runtime_session/scene_ops.zig` |
| Audio PCM + cue | `audio/audio.zig`, `audio/mixer.zig` |
| Scoped shader bytes | `runtime_session/assets.zig`, platform shader/material loaders |
| GPU ownership + editor telemetry | `graphics/residency/` |
| Editor panel | `editor/panels/residency_panel.zig` + `residency/{model,nav,overview,…}`, contrib id `residency` |
