Skip to content
hikari
RenderingEditorAIToolchainDocumentation
GitHub
hikari

© 2026 Flying Rat Studio.
All rights reserved.

Explore the engineDocumentationContributorsLicenseBack to top
Documentation / Systems
Browse docs
Overview
Tutorials16
OverviewFirst game projectFirst entityAuthored user_data and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesPlay, Edit, and scenesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides16
OverviewDevelopment guideGameplay APIChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / UnrealActor and component lifecycle
Systems28
OverviewArchitectureApplication lifecycleFrontends and driversPlatforms and supportSession services and cross-scene stateScenes and gameplayActor communication (hi.actors)Game-facing refsSave / replication wire versionCoordinate space and camera conventionsRenderingRenderer architecture mapFrame governorGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)UI and editorEditor asset hot reloadEditor Project Selector
Language reference2
OverviewAkari language referenceKawa language reference
Engine overview
Start exploring
  • No matching sections. Try fewer words or another topic.
NavigateEnter Openesc Close
Overview
Tutorials16
OverviewFirst game projectFirst entityAuthored user_data and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesPlay, Edit, and scenesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides16
OverviewDevelopment guideGameplay APIChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / UnrealActor and component lifecycle
Systems28
OverviewArchitectureApplication lifecycleFrontends and driversPlatforms and supportSession services and cross-scene stateScenes and gameplayActor communication (hi.actors)Game-facing refsSave / replication wire versionCoordinate space and camera conventionsRenderingRenderer architecture mapFrame governorGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)UI and editorEditor asset hot reloadEditor Project Selector
Language reference2
OverviewAkari language referenceKawa language reference
Engine overview
Systems29 min read

Asset residency

On this page
On this pageMental modelWhat is tracked whereLayer 1 — AssetStore (shared CPU decode)Concurrency contractLayer 2 — MaterialCache (shared materials)Layer 3 — Entity-unique (not in AssetStore)Layer 4 — GPU (render thread)Layer 5 — Audio worker (decoded PCM)Who retains what (consumers)What happens at refs == 0Residency budget (keep-alive tier)GPU texture and mip streamingOpen-world integration seamGenerated / GPU geometryResidency tickets (readiness as an event)The residency pass (stage table)The cursorDebtThe stall watchThe budget knob — off by default, and whyDecode is off the game threadThe unit of work is a primitive, not an actorMaterials are the expensive halfOne stage, both consumersRetain ownershipTerminationScene transitions and streamingAdditive layersFull replace (overlap-safe)Stream cellsSingle-asset holds: Retained(T)DiagnosticsInvariants (tests / mental checklist)Quick code map Back to top

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”).

text
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 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)

KindPayloadAPI
TextureDecoded image (+ optional GPU handle)retainTexture / releaseTexture / hasTexture
ModelCooked model parseretainModel / releaseModel / hasModel
Engine meshDual payload: rigid indexed 48 B verts + u32 indices or skinned indexed 64 B verts + u32 indicesretainEngineMesh / releaseEngineMesh / hasEngineMesh
Audio cueCooked audio bytesretainAudioCue / releaseAudioCue / hasAudioCue
FontParsed TrueTyperetainFont / releaseFont / hasFont
BlobOpaque bytes (scripts, scene JSON, collision, materials, particles, animgraphs, …)retainBlob / retainBlobKind / releaseBlob / hasBlob / retainPinnedBlob
Shader artifactPlatform blob for a package roleretainShader / 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:

FieldMeaning
refsNumber of explicit retains (materials, meshes, scopes, short-lived parses, …)
pinExplicit 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)

ItemTracking
Material template / MIPath-keyed refcount (retain / release)
Textures on a materialStore retainTexture only for successfully bound slots (slot non-null ⟺ retain held); rebind retains new before releasing old
Layer / scene listsworld.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)

ItemLifetime
Components, scripts, user_dataDespawn / unload
VertexBuffer CPU mirror on primRebind / prim deinit
ProceduralMesh vertex store (SDK)Owner's deinit
Physics bodiesEntity release → deferred physics free
Script VM instancesEntity script destroy

Layer 4 — GPU (render thread)

ItemTracking
Geometry / material / pipeline handlesRenderResidency refcounts
Sampled texture GPU objectsCreated when used; freed via pending_texture_frees → release_texture when CPU texture last-ref frees
Vertex/index GPU buffersReleased 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)

ItemTracking
Cooked cue in storeretainAudioCue while a decoded entry exists
Decoded stereo PCMAudioSystem.decoded map; held while any voice still samples that buffer
FreeWorker 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)

ConsumerHoldsReleases when
Layer material_pathsMaterials (→ textures)Layer unload / full unload
MaterialCache entryTextures in descMaterial refcount → 0
Bound RenderPrimitiveEngine mesh pathRebind, error mesh, procedural setMesh, entity despawn
SkyboxCubemap textureClear / replace path / world destroy
Audio play pathCue + decoded PCMNo voice uses samples
Short parse (material JSON, model_doc, scene, collision, input_actions, script bytecode)Blob for the calldefer releaseBlob after parse / loadBytecode
ShadersBlobScoped retain around native Metal/D3D12 copy/compile or byte hashing; released immediately afterward
content://BlobScoped host retain while the SDK copies into caller-owned memory; released before Content.read returns
Kawa Content.readBlob brieflyAfter copy into Kawa string
beginReplacePreloadMaterials + store scope (meshes + visual-zone grading textures) for incoming sceneAfter replace load completes (SceneResidencyPreload.deinit)
ResidencyScopeWhatever the scope retained (also used inside scene preload for store assets)releaseAll / deinit (stream cell exit / preload bridge drop)
Editor inspector / texture dropTemporary retainEnd of inspect / drop prepare

What happens at refs == 0

ResourceUnpinned refs → 0Pinned
Texture (CPU)Destroy immediately, or park when keep-alive is configured; GPU handle always frees deferredStays until last unpinPath
ModelDestroy immediately, or park when keep-alive is configuredStays while pinned
Engine mesh soupFree immediately, or park when keep-alive is configuredStays while pinned
Audio cue (store)Destroy immediately, or park when keep-alive is configured (after decoded PCM drops its retain)Stays while pinned
FontDestroy immediately, or park when keep-alive is configuredStays while pinned
BlobFree immediately, or park when keep-alive is configuredStays only for an explicit long-lived pin
MaterialCache entryImmediate destroy material + shader (if owned) + release texturesN/A (no pin map; use store pin for textures)
Decoded audio PCMImmediate free when no voice samples it (worker sweep)N/A
GPU geometry / pipelinesWhen RenderResidency count hits 0 → deferred freeN/A
GPU textureAfter CPU texture free enqueues handle → render thread deferred freeN/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/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 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*ActorContext): 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.

StateMeaning
hotrefs > 0. Never parked, never evicted.
coldrefs == 0, still decoded, counted against the budget, evictable oldest-first.
pinnedOutranks 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,
  "texture": { "gpu_budget_mb": 768 }
}
FieldLayerZero means
keep_alive_mbCPU decoded assetsImmediate destroy at refs == 0
gpu_geometry_mbLive GPU mesh geometry (shared keys + dynamic rings)Unlimited; new uploads always accepted
texture.gpu_budget_mbGPU bytes for ordinary 2D material-map mip chainsMip 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:

  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. 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.

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.

KeyingWhen
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).

text
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, 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.

StageWakes onDecodes
skyboxtexture
visual_zonetexture
decaltexture
particleparticle
material_upgradetexture, materialyes
fog_volumematerial
geometrymodel, model_doc, animation_graph, scriptyes

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 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:

PathDispatcher
Gated asset needed before scene presentationpreload.dispatchStoreWork
Immediate scene asset or asset arriving after spawnresidency_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.

text
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 kindCollected whenWorker does
.materialentry is_error, document .readyparse the doc off-thread, retain its textures
.texturelive material has an unbound slotretainTexture
.engine_meshprimitive soft-pending, mesh .readyretainEngineMesh

.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
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
    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.

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):

text
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 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:

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

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.

ToolRole
checkLeaks() → LeakReportentry_count, total_refs, pinned_paths, unpinned_entries, per-kind counts
logLeaksIfAny(context)Warn + sample paths on teardown
appendLiveSnapshot / copyRecentFrees / copyTicketEntriesLive path/kind/refs/pin; last-48 free ring; every ticket + what it waits on (--profiler-residency=cpu)
geometry_residency.zigCPU mesh used vs held, static vs dynamic, staging free lists
RenderResidency.geometryStatsGPU hashed / identity / instanced / dynamic-ring bytes
gpu_residency.zigSynchronized GPU snapshot (textures, buffers, programs, PSOs, AS)
Editor Residency tabRight 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, 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.
  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 committed, and commit counts work rather than attempts.

Quick code map

ConcernFile
Store entries and public surfaceassets/asset_store/store.zig, assets/asset_store/types.zig
Keep-alive tier, budget, evictionassets/asset_store/budget.zig
Shared retain/release coreassets/asset_store/residency.zig
Materials + texture edgesassets/material_cache.zig
Stream-cell bulk retainassets/residency_scope.zig
Single-asset RAII holdassets/retained.zig
Procedural geometry + identity keyingsdk/src/procedural_mesh.zig, graphics/vertex_buffer.zig
Mesh bind / probe / scene preloadscene/scene_loader.zig, render_component.zig
Residency pass, stage table, debtscene/world/world_residency.zig
Frame slice + min-progress rulescene/residency_budget.zig
What a wake deliveredscene/residency_filter.zig
Scene replace wiringruntime_session/scene_ops.zig
Audio PCM + cueaudio/audio.zig, audio/mixer.zig
Scoped shader bytesruntime_session/assets.zig, platform shader/material loaders
GPU ownership + editor telemetrygraphics/residency/
Editor paneleditor/panels/residency_panel.zig + residency/{model,nav,overview,…}, contrib id residency
PreviousPrefabsNext Asset formats (Shinra pipeline)

Documentation follows the current engine checkout.

Snapshot cc148c75Source docs/systems/asset-residency.md
On this pageMental modelWhat is tracked whereLayer 1 — AssetStore (shared CPU decode)Concurrency contractLayer 2 — MaterialCache (shared materials)Layer 3 — Entity-unique (not in AssetStore)Layer 4 — GPU (render thread)Layer 5 — Audio worker (decoded PCM)Who retains what (consumers)What happens at refs == 0Residency budget (keep-alive tier)GPU texture and mip streamingOpen-world integration seamGenerated / GPU geometryResidency tickets (readiness as an event)The residency pass (stage table)The cursorDebtThe stall watchThe budget knob — off by default, and whyDecode is off the game threadThe unit of work is a primitive, not an actorMaterials are the expensive halfOne stage, both consumersRetain ownershipTerminationScene transitions and streamingAdditive layersFull replace (overlap-safe)Stream cellsSingle-asset holds: Retained(T)DiagnosticsInvariants (tests / mental checklist)Quick code map Back to top