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 · Refs · Session services · Rendering · Renderer architecture
Contents: Mental model · Tracked layers · Consumers · Free at zero · Budget · Residency pass · Texture / mip streaming · Generated / GPU geometry · Scene / stream · Diagnostics · Invariants
Mental model
No session-immortal cache. 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 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 ─► sameThe 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 are extension-free stems (asset_kind.stemOf / logical path). Cooked suffixes are packaging, not identity.
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 48 B verts + u32 indices or skinned indexed 64 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.
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; never use it merely because a consumer copies bytes |
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 (material JSON, model_doc, scene, collision, input_actions, script bytecode) | Blob for the call | defer releaseBlob after parse / loadBytecode |
| Shaders | Blob | Scoped retain around native Metal/D3D12 copy/compile or byte hashing; released immediately afterward |
| 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):
forceEvictFamilyinscene_ops— (1) abandon world holders for that family only (texture → skybox/probes/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 entryreload_pending. Material slots must still be released beforeevictFamilyForReload, or a followingrefreshTextureAssetretain-new + release-old on the same path destroys the fresh decode. Same stem + different kind is valid: a.shintextureevent must not wipe a model/audio/font/blob at that stem. Model companions (.shinmodel/.shinmodeldoc/.shincollision) share the model family and one wipe.ReloadFamilyistexture/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 becomereload_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*ActorContext): debug assertsrefs == 0. Force-evict never bypasses live refs: a still-borrowed entry is markedreload_pendingand destroyed by its last release; it never invalidates a live blob lease. AssetStore.deinit/ session teardown:logLeaksIfAnywarns 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. |
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 residencyStandalone 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:
"residency": {
"keep_alive_mb": 512,
"gpu_geometry_mb": 1024,
"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 |
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).
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.
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:
- 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. - Visible material geometry submits projected screen coverage. Demands for a shared texture combine by highest required detail and priority.
- 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.
- 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.
- 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.
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.
The editor Residency → GPU page reports current/budgeted GPU texture bytes, current CPU mip-payload bytes, resident and full-detail counts, pending transitions, and any unavoidable tail-floor overflow. A non-zero overflow means the configured budget is smaller than the guaranteed tails and full-resolution pins already resident.
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 (resolveApplyBudget) 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 ApplyBudget.target_ns, floored at one and ceilinged by create_ceiling. What counts is createsGpuObjects, not the command name: applyUpdatePrimitiveMaterial compiles pipelines exactly as a create does and is budgeted the same way.
Queue-owned CPU geometry is also admitted by bytes: 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. 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.
Draw / upload details: Rendering, Renderer architecture.
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. sealis 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.
drainResidencyWakesreturns 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.
WakeModeis the presentation policy in disguise:.progresswakes per asset (incremental binding, the editor's pop-in),.completewakes once when the set is done (gates).- A failed asset satisfies, degraded.
failed > 0opens 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, probes, zones, decals, particles); hi.host_api.residency() 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 eight 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 | |
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 intickPublishis deliberately not anelseof 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 pass is a store hit plus a vertex staging copy, and a fixed slice cannot tell that apart from a decode. Pressing Play on a large scene binds a fresh World against an already-hot store in a few hundred milliseconds of memcpy; 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 committed, 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, probes, 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? debtsubmitColdDecodes 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:
Diagram source
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
endTermination
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.
ActorContext.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 is the copy into the primitive's buffer and is unavoidable there. 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 onlyFor 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
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 for the op queue, presentation policy and cancellation rules; hi.host_api.residency() 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:
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-releaseDiagnostics
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)
- After full unload, scene shaders and previously read
content://tables are gone or cold-budget entries, never pins. Any remaining pin must have an explicit long-lived owner. - Two actors, same mesh: despawn one → mesh stays; despawn both → mesh free.
- Material release frees unique textures; shared albedo used by two materials survives the first release.
- Additive A+B shared texture: unload A → stays; unload B → gone.
- Replace with shared assets: preload keeps shared resident across unload (no free+reload of shared paths when preload succeeds).
- Soft pending mesh: no mesh retain until bind succeeds.
- Bundle IO release with live decoded refs does not destroy those entries.
registerfailing at any allocation leavesmaterial_refs/pipeline_refs/geometry_refsempty.- A completed texture upload binds the same maps whether the pending index or the overflow scan runs.
Retaineddeinit is idempotent; a moved-from token releases nothing.statesstays bounded by resident assets — destroy paths remove the key rather than storing.pending.- A
(stem, kind)waiter is satisfied only by that kind — a material landing at a model's stem must not satisfy the model waiter. - A ticket that outlives its owner decrements into a reused slot: cancel on abort, close once the edge is consumed.
- Nothing reacts to
state_generationany more. It survives as a diagnostic counter only; re-introducing a consumer re-introduces the rescan storm. - A probed-absent asset is not re-probed until a pack opens or closes (
probe_epochvsbundle_epoch). Absence is provisional, not cached forever. - 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.
residency_work_pendingis 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.- A budget slice always completes at least one unit of work.
Budget.exhausted()reports false until something iscommitted, andcommitcounts 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 |