# Assets and Shinra

Hands-on walkthroughs: [First mesh and material](/docs/tutorials/first-mesh-material.md), [Assets in Play](/docs/tutorials/assets-in-play.md).

## Source and runtime assets

Games keep source assets under their package, normally `hikari-games/example/assets/`:

| Source | Processed output |
|---|---|
| PNG/JPEG/EXR/HDR/DDS/TGA | `.shintexture` |
| glTF/GLB, FBX, OBJ, USDZ | `.shinmodel` (+ companions: textures, `.model.json` / `.shinmodeldoc`, materials) |
| WAV/OGG | `.shinaudio` |
| TTF | `.shinfont` |
| Kawa scripts | `.kawabc` (via `kawac`; `--kawac=<path>`) |
| `.material.json` / `.model.json` | `.shinmaterial` / `.shinmodeldoc` |
| `.particle.json` / `.animgraph.json` | `.shinparticle` / `.shinanimgraph` |
| shader package/spec | platform shader artifacts |

Shinra also cooks scenes (`.shinscene`) and prefabs (`.shinprefab`). Wire details: [Asset formats](/docs/systems/asset-formats.md).

The engine's runtime asset APIs are in `src/hikari/src/assets/`. Three layers:

1. **Game / engine** — only `AssetStore` / `AssetRef` / scene_document (no packaging knowledge). Load is `retain*` / `release*` / `pinPath`; `has*` is a bool probe, never a borrowed pointer.
2. **Asset facade** — `AssetStore` caches and decodes; all raw IO goes through `assets/io.AssetIo`. The vtable returns an ABI-stable `Status` byte with out-params, never a Zig error union: the store is compiled into every backend dylib while the host implements the table, and error values are numbered per compilation. Implementers register through `asset_io.adapt.*` and keep writing `Error!` functions.
3. **Backend** — default `assets/backends/shinra_io.zig` (comptime) implements packs + loose/data trees. Wire formats live in `assets/formats/`; `.shinbundle` open lives under `assets/bundle/` and is **only** used by that backend.

The editor live loop uses `editor/asset_pipeline/daemon.zig` (tool protocol; brand isolated there).

## Asset pipeline (Shinra tool)

Shinra (`src/shinra`) is the Rust asset compiler. Kaji builds it to **`bin/shinra/<platform-arch>/shinra`**. It processes inputs in parallel, supports sidecar metadata, watch mode, deterministic manifests, optional bundles, and validation. **`--target macos-metal|windows-d3d12|linux-vulkan`** drives texture compression (not runtime asset code). Kawa scripts cook via **`--kawac <path>`** (Kaji passes the staged `kawac`) or `kawac` on `PATH` when invoking Shinra directly without an override.

All independent asset work uses one batch-outcome contract, including plugin assets (audio, textures, models, scripts, fonts), authoring companions, particles, scenes, prefabs, and shader specifications. A source-local parse/import/compile failure records only that source as failed; sibling work and later phases continue, every successful output is published immediately, and the manifest is generated from the usable output tree before the batch reports an aggregate error. An outer pipeline error is reserved for infrastructure failures that prevent a phase from running, such as an unreadable input tree or unavailable required tool. Watch mode follows the same isolation rule: a failed change never invalidates sibling outputs, and atomic producers retain the failed source's last-known-good artifact.

On Windows, Kaji sets `LIBCLANG_PATH` in the Cargo child environment from known LLVM / Visual Studio Clang install paths when building Shinra (bindgen needs `libclang.dll`). An existing valid `LIBCLANG_PATH` is left unchanged. The same pass sets `CMAKE` (and prepends its directory to `PATH`) from the Visual Studio CMake component or a standalone CMake install so `opusic-sys` can compile libopus. An existing valid `CMAKE` or a `cmake` already on `PATH` is left unchanged.

Shinra also needs a working C toolchain on every build host: `ufbx` is compiled through `cc` (same as `intel_tex_2` / `audiopus`). The generated Rust bindings carry no rustdoc — read `ufbx.h` for semantics. `audiopus` is the Flakebi/audiopus git fork (pinned `rev` in `Cargo.toml`) for Windows-friendly linking; do not switch to crates.io-only without validating Windows builds. Ogg sources sniff `OpusHead` vs Vorbis ident: Vorbis decodes (lewton) then cooks; Opus + `format: OpusOgg` pass through into `.shinaudio`; Opus + PCM format is a hard error (use WAV/Vorbis). Do not feed Opus Ogg through lewton.

Kaji builds Shinra as **release** by default even when the product is `--config=debug` (host tool). To debug the pipeline itself:

```bash
bin/kaji/kaji editor --project=../hikari-games/example --shinra:config=debug
# equivalent:
bin/kaji/kaji editor --project=../hikari-games/example --dep-config=shinra:debug
bin/kaji/kaji shinra --shinra:config=debug
```

| Target | Texture compression |
|---|---|
| `windows-d3d12` | BC7 |
| `macos-metal` | ASTC 6x6 |
| `linux-vulkan` | BC7 |

Sidecar files use `<filename>.shinmeta.json`. They configure texture mipmaps/sRGB/premultiplication/compression quality, audio normalization/sample rate/format/streaming, and model LOD/optimization/compression behavior. Model `allow_cpu_access` defaults to `false`; enable it only when runtime code must inspect decoded render geometry. Static rendering and separately cooked collision do not need it.

## Common command shape

```bash
shinra --input assets/ --target macos-metal
shinra --input assets/ --output build/assets --target windows-d3d12 --validate
shinra --input assets/ --target macos-metal --watch
shinra --input assets/ --target windows-d3d12 --bundle game --compression zstd
# Domain pack (product seal / Kaji --resources=bundles): logical root ≠ output
shinra pack --name scenes --root data/ --output data/bundles/ --subdir scenes --include-ext shinscene
```

The default output mirrors source folders and emits `assets.shinmanifest.json` plus `packs.shinplan.json` (resolved pack membership). **Kaji product cook** always runs **`--bundle game`**, writing intermediate **`game.shinbundle` + `game.shinbundle.json`** under the cook work tree (TOC membership only — no per-stem `.map.json`). That intermediate is **not** a product catalog stem; seal strips any copy that lands under `data/bundles/`. Editor watch (`--watch`) does not pass `--bundle`.

Product builds seal **logical domain packs** via Kaji `--resources=bundles|loose` (default bundles): **`configs/layout.json`** (v2) defines each pack's membership globs, **delivery** (`packed` or `loose`), **deps** and **labels**; Kaji always seals **`engine`** on full game builds. With no `layout.json` the whole project ships as one packed pack. Seal places what the plan says and writes **`{stem}.shinbundle`** (v4, TOC embedded; the `.shinbundle.json` beside it is informational), then writes the **`product.shinbundle.map.json`** v3 catalog: per pack delivery, deps, labels and loose root — a packed pack's membership is its own TOC, which the runtime maps at store open (not a mega-pack, not the cook `game` stem). See [Product pack layout](/docs/guides/project-file.md#example-configslayoutjson-product-pack-layout).

## Scene documents

- **Authoring:** any `.json` under `assets/` whose top-level `kind` is `com.hikari.scene` (editor always has UTF-8). No folder is special — `assets/scenes/main.json` and `assets/levels/act1/intro.json` are both scenes, told apart from other documents by their format, never by location. The same rule classifies every authored document (`.material.json`, `.prefab.json`, …): one suffix table plus the `kind` tag for bare `.json`.
- **Cook:** Shinra owns it as part of the ordinary authoring pass over `--input`. Emits `<same relative path>.shinscene` (the document as a schema-neutral value tree in the cooked container) under the cook/work output only — **not** next to authoring JSON. Kaji stages every cooked scene to the same relative path under `data/` (then seal may pack). Watch mode re-cooks into the cache/work tree. A scene whose root `partition` is enabled additionally emits `<stem>/cells/cell_<x>_<y>_<z>.shinscene` per occupied cell and sweeps cell layers it no longer produces (`removed` events) — see [World partition](/docs/systems/scenes-and-gameplay.md#world-partition-authoring). The sweep removes only generated cell files and retains directories shared with concurrent material, model and other asset cooks.
- **Runtime load:** only via **AssetStore** (`scene_document.loadFromStore`), only the cooked `.shinscene`, in every build. Loose / `data_root` / bundle — store decides. The editor opens its JSON documents itself and hands the decoded scene to the session.

## Prefab documents

Full expand/diff/variants/editor/runtime contract: [Prefabs](/docs/systems/prefabs.md).

- **Authoring:** `assets/prefabs/**/*.prefab.json` with `kind: com.hikari.prefab` (editor always has UTF-8).
- **Cook:** part of Shinra's ordinary authoring pass over `--input`, like scenes: any `*.prefab.json` (or bare `.json` with `kind: com.hikari.prefab`) anywhere under `assets/` cooks to `<same relative path>.shinprefab` (same scheme as scenes; envelope validates prefab fields) under cook/work only. Kaji stages it to the same relative path under `data/`. Watch re-cooks into the cache/work tree.
- **Runtime load:** AssetStore, cooked `.shinprefab` only; `World.retainPrefab` decodes it without JSON. The editor reads authoring prefabs through its own `PathLoader`.

## Asset URIs and packs

Game modules use typed **`hi.AssetRef`** for dynamic binds; scene JSON stores the same URI strings. See [Game-facing refs](/docs/systems/refs.md).

Cooked assets are **`asset://` only**. `content://` (AssetStore tables under `content_dirs`), `writable://`, and `project://` (filesystem) are **`ContentRef`**, not cooked-asset URIs.

**Two `asset://` prefixes** (no pack-search / `.../` form). Bare paths are an unscoped alias:

| Form | Meaning | Pack selection |
|------|---------|----------------|
| `asset://<pack>/<logical/path>` | Absolute pack + path | Pack is in the URI |
| `asset://./<logical/path>` | **Unscoped absolute** path under product data | Product catalog `path → pack` (seal map) |
| bare `<logical/path>` | Same as unscoped (catalog key) | Same as `./` |

**`./` is not FS-relative** (not “next to this model / this scene file”). It means *no pack segment in the URI*. The path is still absolute under the product data root (`models/cube`). Shinra bakes multi-mesh GLB companions this way (`asset://./scenes/…/DamagedHelmet_img0`) so pack membership can move without rewriting every companion ref.

Engine-owned meshes do not enter Shinra or scene preload residency. In addition to
`asset://_engine/meshes/unit_plane`, scenes can request an indexed displacement grid as
`asset://_engine/meshes/grid_plane_<segments>` (1–512). Shared vertices make a 256×256
surface roughly one sixth of the vertex upload and vertex-shader work of triangle soup.

### Resolve rules (packs mode)

1. **Explicit** `asset://shared/models/cube` → AssetStore acquires pack `shared` automatically for the read.
2. **Unscoped** `asset://./models/cube` → look up `models/cube` in the **product catalog** (one path → one pack at seal). AssetStore acquires that pack automatically for the read. **Does not** search across open packs.
3. **Uncatalogued path inside a `loose` pack's root** → acquires the owning loose pack and resolves from disk. This is the mod case: a file the seal never saw behaves exactly like a sealed member.
4. Anything else uncatalogued (content dirs, always-loose leftovers) resolves from the data plane as before.
5. Seal **dual-home** is a build error: one logical path, one pack. Valid catalogs never need "first retained pack wins".

| Job | Mechanism |
|-----|-----------|
| **Which packs are open** | Session system packs, scene/request dependency closures, automatic reads, and explicit `hi.packs()` holds |
| **Which pack owns a path** | Explicit URI pack, or product catalog for `./` |
| **What goes in a pack** | `configs/layout.json`, resolved by the cook into `packs.shinplan.json` |
| **What a retain opens** | The pack **plus its `deps`**, transitively |

### Scenes do not declare packs

A scene document has no `packs` field. Which packs a scene needs is computed at load from the assets it references:

```
closure(scene) = packs(transitive asset dependencies(scene refs)) + explicit pack deps
```

`scene/scene_pack_closure.zig` does the first half; the backend's `retainPack` does the second. Consequences worth knowing:

- A scene **cannot** name a pack that does not exist, or drift from `configs/layout.json`.
- Runtime scenes and prefabs are cooked documents. Added content needs cooked artifacts and dependency metadata; the product never parses authoring JSON.
- `assets.shingraph` supplies transitive, kind-specific dependencies before payload packs open. Model documents, materials, textures, prefabs, shaders and typed component references can live in different packs. Explicit pack `deps` remain available for content selected by code.
- An older document's `packs` array is ignored with a validation warning.

The editor's **Scene Packs** tab (left dock) shows the closure and which reference pulled each pack in.

Shared art is referenced either as `asset://shared/models/cube` (pack explicit, greppable) or `asset://./models/cube` (catalog maps path → `shared`). Neither needs a scene-side declaration.

Scene jobs first dispatch cooked document IO and prefab expansion, then prepare the partition base and collect references from shared document storage. Preparation decodes component references once; pack discovery and sliced residency planning reuse that result. Cancelled document and decode workers retain their own storage until completion, so supersession and cell departures never join them on the tick thread. Detached decode batches and material descriptions use the asset store's heap, which survives Play retirement. Adoption transfers descriptions into the material cache's heap without parsing again. A failed submission stays queued for a later slice; only explicit callers without dispatch use synchronous preload.

Each decoded cache identity has one active decoder. Concurrent followers return pending and retry after publication; workers never wait on another decoder. Logical pack admission and mapping lifetime are separate: closing the last pack hold stops new reads, while decoded leases preserve existing bytes. A later open reuses that mapping until its final lease disappears. Integrity failures report IO failure, allocation failures report OOM, and only an absent asset reports an unknown path.

Editor live tree: the store opens with the **cook plan as its catalog** (`packs.shinplan.json`, parsed by `BundleCatalog.parsePlan`), so `hasPack`, `packOf`, `packsForLabel` and `hi.packs()` answer exactly as the product will. Retains are advisory there — nothing is mapped and no read is ever gated — but they are counted, so `stat` and the handle API behave the same in Play-in-editor as in a packed build. A tree that has not cooked yet has an empty catalog and stays fully readable. A pack retained before the plan names it is held empty and is counted, with its dependencies, when a later cook's plan does — so its release always pairs with its own retain. Cook batches that leave the plan bytes unchanged do not reload it.

### Opening packs from game code

`hi.packs()` — `open(stem)`, `openLabel(label)`, `state(handle)`, `close(handle)`, `stat(stem)`, `list`, `handlePacks`. Handle-based so a retain is attributable and Play/Stop can drop what game code held. `open` registers an explicit storage hold and returns immediately. Cold mappings and dependency opens run on workers; `state(handle)` reports `pending`, `ready`, `failed`, or `closed`. An already-open root completes without disk work. Close cancels a pending handle without waiting and balances only successful retains. Label handles own a dynamically sized stem list. This is storage acquisition; use asset requests to load decoded payloads. AssetStore reads and SDK residency requests acquire storage automatically. Use explicit pack handles for grouping or longer storage lifetimes. Delivery is invisible at this layer: a loose (moddable) pack and a packed one open, count and close identically. `stat` and `list` are two catalog lookups with no allocation (`AssetIo.packRefs` / `packStems`), so polling them per frame is fine.

Every scene load — sync, job, and the preload bridge — retains its closure through one function, `scene_pack_closure.retainPacks`, and every walk over a scene's references (closure, Scene Packs tab, tests) goes through `scene/scene_asset_refs.zig`. A reference added to an actor descriptor is added there and nowhere else.

### Cooked dependency graph

Shinra publishes `assets.shingraph` alongside the manifest and pack plan. Kaji stages it in both loose and bundled products and records it as a required build output. The binary format is defined by the engine’s `cooked_layout`; reference descriptors are rendered for Shinra from the shared engine rules. Nodes use full cooked paths so a mesh, model document, and collision payload at one stem remain distinct. Edges carry source-field provenance.

Scene and prefab documents participate in manifest discovery, including generated scenes. Partition base-to-cell edges are **deferred**: inspecting the base reveals them, but loading it does not acquire every cell. Each admitted cell resolves its own asset closure. Scene jobs resolve and open dependency packs on workers; abandoned preparation owns its references and drains without borrowing a destroyed World.

Graph snapshots are immutable and retained across publication. Graph walks run outside the AssetStore lock. Pack closures grow dynamically and pin their dependency membership until the final root hold is released; a live layout edit cannot make a release decrement a different set. Public pack names remain stable for the store lifetime. Decoded byte leases protect mappings independently of logical pack holds.

The editor’s **Asset References** window defaults to the cooked graph. Turn off **Cooked** for the authored-document view. Both Uses and Used by include transitive navigation; tooltips show provenance, deferred cell edges, pack placement, cooked bytes and current CPU holds. The graph rebuilds when the cooked revision changes, even if the asset count is unchanged. The canvas reports truncation at its display limit; the underlying graph and runtime closure remain complete. **Scene Packs** uses the same transitive closure. The selection rail summarizes the complete graph independently of canvas limits, deduplicating shared dependencies and excluding deferred cells by default. Pack/missing/deferred/cross-pack filters preserve connecting paths. Cooked-byte totals describe stored content, not CPU/GPU residency; missing targets make them lower bounds. Queries refresh on index or selection changes, rather than traversing the graph each frame.

For an offline export, run `shinra graph --root=<cook-tree-or-product-data> --output=<report.json>`. The report includes unresolved targets for diagnosis; unresolved optional content is not silently erased.

### One place resolves membership

`configs/layout.json` is authored; **`packs.shinplan.json` is resolved**. The Shinra cook matches the `include` globs against the cook tree once and writes the plan beside `assets.shinmanifest.json`. Kaji seals what the plan says; the editor reads it in **Project Settings → Packs** and in the **Scene Packs** tab. Neither matches a glob, so the partition the editor shows is the partition the product ships. Watch mode rewrites the plan when the cook tree changes and when `layout.json` is saved.

Implementation: `assets/asset_ref.zig`, `assets/backends/shinra_io.zig`, `assets/bundle/catalog.zig`, `scene/scene_pack_closure.zig`, `game_api/host_bind/packs_api.zig`, Shinra `pipeline/pack_plan.rs`, Kaji `ProductResourcePacker` + `PackPlan`.

## Asset residency (refcount)

Shared CPU decode lives in **`AssetStore`**: retain/release, optional pin, immediate free at `refs == 0` when unpinned. Materials, bound meshes, skybox/probes, audio, shaders, and scene loaders are the consumers.

**Full overview** (what is tracked, who holds what, `refs → 0` policy, overlap/streaming, diagnostics): **[Asset residency](/docs/systems/asset-residency.md)**.

## Game and editor behavior

### Launch matrix (first-class; no dual-read fallback)

One rule: **the stage on disk is the product.** Kaji (Shinra cook + Akari + seal) writes it; the runtime opens whatever layout is present. `--run` is optional — the same tree is what a manual launch or host package (`.app`) uses. `--clean` wipes stages; rebuild restages; open never invents a second cooked root at load time.

| Launch | Who cooks | Asset plane | Layout |
|--------|-----------|-------------|--------|
| `kaji editor --project=…` (+ optional `--run` / `--clean`) | Shinra **watch** (live) | `<project>/.engine/cache/shinra` | Always loose. Engine ship shaders are **staged into that tree** after Kaji compiles them (and materialized from product install on cold project open). |
| Standalone editor opens a project | Shinra **watch** (live) | same live work tree | same; product install supplies `_engine/shaders` into the live tree if missing |
| Editor project selector (no project) | n/a (chrome only) | product `<exe>/data` | data-only (`_engine/shaders` for device-init) |
| `kaji game --project=… --resources=bundles` (+ optional `--run` / `--clean`) | Shinra **batch** + seal | product `<exe>/data` | domain packs under `data/bundles/` + `product.shinbundle.map.json` |
| `kaji game … --resources=loose` | Shinra **batch** + loose seal | product `<exe>/data` | loose files under `data/`; no catalog map |
| `kaji editor …` (+ optional `--package` / `--run`) | Shinra **watch** only | project `.engine/cache/shinra` | **No product packs.** `--resources=` / `--data-layout=` are rejected. Host `.app` packaging is orthogonal (`--package=`). |
| Game packaged as `.app` / zip | sealed at package time | product `data/` next to exe | same as stage (`bundles` or `loose`) |

Runtime selection is **self-describing** (`runtime_session/assets.zig`):

1. `loose_assets_root` set → **editor live** (cooked tree only; project/content FS is a separate plane for authoring/`content://`, not a second look-up for cooked binaries).
2. else `data/bundles/product.shinbundle.map.json` present → **product packs**.
3. else → **product loose** (entire cooked tree under `content_root` = `data/`).

There is no “try packs then fall back to loose files for packed domains,” and no “read engine shaders from product data while the live tree is empty.” Missing engine metallibs in the live tree are **materialized once into that tree** from the product install, then opened as the single cooked root.

Editor watch layout (under the project `.engine/` tree — do not commit):

| Arg | Path |
|---|---|
| `--output` / `--cache-dir` | `<project>/.engine/cache/shinra` (editor live loose cook + staged engine shaders) |
| `--shader-cache-dir` | `<project>/.engine/cache/shaders` (Akari / AIR intermediates) |

**All project-local engine artifacts live under `<project>/.engine/`** (clean cut — project root is source only: no `data/` stage, no `assets/.shinra`, no cooked siblings):

| Path | Role |
|------|------|
| `.engine/build/shinra` | Offline cook (`kaji assets` / product asset step) |
| `.engine/staged/data` | `kaji assets` product-data preview / seal (not for `--run`) |
| `.engine/cache/shinra` | Editor live cooked plane (watch + engine ship shaders) |
| `.engine/cache/shaders` | Shader compile cache |

Shipped / runnable product still stages under **`bin/…/data/`** (runtime `exe_dir/data`), including packs in `data/bundles/` when `--resources=bundles`.

Batch CLI runs still default shader cache to `<output>/.shader-cache` unless those flags are set.

Kaji seeds the editor's `.engine/cache/shaders/<config>` from the engine build's Akari cache so the startup recompile is all hits. Only the content-addressed artifacts are portable. Clang's module cache (`clang-module-cache/`) records the absolute path it was built at, so the seed skips it. Akari also discards any module cache whose `clang-module-cache.origin` names another location, which covers copied or moved projects too.

### Editor live protocol

Shinra watch mode speaks a line protocol on **stdout** (human logs on **stderr**). Handshake is **`@shinra hello v6`**; the editor refuses any other version:

| Line | Meaning |
|---|---|
| `@shinra hello v6` | Protocol handshake (v6) |
| `@shinra busy` / `@shinra idle` | Compile activity |
| `@asset pending\|available\|rebuilt\|failed\|removed <key> [type=T] [size=N] [gen=N] [hash=H]` | Logical asset change (`available` is a byte-identical cache hit; `type` is stable AssetKind token; `gen` is process-monotonic; editors ignore stale gens; `hash` is the output's content identity) |
| `@asset catalog` | Manifest/catalog reshaped |
| `@compiled <id> ok\|error\|busy` | Answer to a compile request |

The editor supervises the child over pipes, coalesces events per path (bounded, 8192 unique paths), **defers apply while Play is active** (`@shinra busy` is UI status only), and reloads the scene only when a terminal event references authored actors. Dropped queue notifications trigger one `@cmd rebuild-all` per overflow burst (debounced through busy→idle / catalog); daemon disconnect keeps soft-pending (child `process_all` on restart recovers). **Stdin** accepts `@cmd quit`, `@cmd rebuild-all`, `@cmd rebuild <source_rel>` (also used for portable Windows shutdown via quit + stdin EOF), and `@cmd compile` (below).

### Compile requests (protocol v6)

The editor has no compiler of its own. A surface sends its current text, saved or not, with a JSON control frame:

```text
@cmd compile {"id":42,"text_path":"/absolute/live-compile/42.json","source_rel":"ui/hud.ui.json"}
```

JSON escaping preserves whitespace and control characters in paths. Control frames are capped at 16 KiB and source text at 16 MiB. The editor becomes connected only after a matching hello. Outbound commands use a bounded queue and a separate pipe writer; commands from a previous child generation are discarded.

Shinra admits at most 32 preview requests across two lanes: ordinary documents and particles requiring native shader compilation. The document lane identifies and forwards particles without waiting for Akari. Native compiles share a process-wide permit with background shader work; waiting interactive requests get priority, with a background turn after eight foreground admissions. Running native compiles are not preempted to admit a preview.

Replies are `@compiled <id> ok|error|busy`. Successful bytes are published atomically at `<text_path>.cooked`; errors go to `<text_path>.error`. Busy replies have no payload and tell live surfaces to retain and retry their latest text. `@cmd cancel <id>` cancels queued work and running native subprocesses. A 20-second preview deadline includes queue wait; cancellation and deadline diagnostics use the ordinary error reply. Pure document compilers check cancellation before and after compilation. The client retires an unanswered ticket after 30 seconds and removes late reply files.

Diagnostics have a heading followed by one indented `  <path>: <message>` line per problem (`root/<id>.<prop>`, `state/<id>`, `transition/<from>/<to>`, or `node/<id>`). `editor/live_compile.zig` retains the latest submitted text through queue saturation and reconnects. MCP asset edits defer their response while compiling, then recheck the project, source ETag and unsaved editor state before writing. Offline, timeout and busy results refuse the edit; no compiler wait runs on the editor main thread. Scenes compile only on save because they produce partition layers.

### Watch scheduling

The filesystem watcher and control/preview services start before the initial cook; source events received during that cook are coalesced by path and processed afterward. This keeps startup edits without allowing initial and incremental cooks to write the same outputs concurrently. Rebuild-all commands set a scan flag rather than expanding the tree on the stdin reader.

Incremental work uses a deduplicated source scheduler. Changes outrank background rebuild jobs, with one background admission after eight changed jobs to prevent starvation. A save during an active cook marks that source dirty; completion requeues it directly in scheduler state and never blocks on a full worker channel. Deletions use the same scheduler, so an active cook finishes before its outputs are removed. Removal reads the source stamp to drop every companion and partition cell, invalidates the stamp, publishes `removed`, and refreshes the catalog. Directory removals also requeue active descendants.

Initial and incremental plugin work and catalog scans share a private Rayon pool, including nested texture encoding. The default watch budget reserves one logical CPU for the editor (minimum one worker); an explicit thread limit overrides it. The pinned ASTC wrapper invokes one native encoder index, so each context allocates one worker's scratch and asset-level concurrency comes from Rayon.

Model dependencies are indexed once at startup, using current stamp inputs where possible. A model edit replaces its dependency entries; texture, buffer and material-library events look up their owners without scanning the source tree. Shared dependencies retain all owners, and textures released by a model return to standalone cooking.

Cook-cache hits require the same target platform, cook flags, current layout and input stats. Import sidecars are checked against the asset-meta schema, including duplicate and unknown keys, before metadata is deserialized.

Asset results are published immediately. A separate catalog worker coalesces manifest/pack-plan regeneration with a 100 ms quiet period and a 500 ms maximum batching window; updates arriving during a scan trigger another pass. Manifest publication is atomic. Shutdown joins cook, preview and catalog workers and flushes the publication ledger.

Toolbar asset-pipeline pill hover opens a list tip (popup chrome): caption header, relative stem rows with trailing kind meta from `AssetStore` (Shinra `type=` / path suffix / explicit retain), capped with an overflow footer. That is the set the editor has observed via Shinra events or soft open misses — not Shinra’s full internal cook queue.

Invariant: every `@asset pending` is paired with a later `available`/`rebuilt`/`failed`/`removed` for that key (watch requeues mid-flight saves).

`failed` is source-scoped, never batch-scoped: Shinra must not fan one failed source out across unrelated cooked keys. A partial batch may therefore emit both `rebuilt` events for successful assets and `failed` for the bad source before returning a non-zero batch result. Shader packages additionally commit their native libraries atomically, so a rejected replacement cannot truncate the library that the running editor is using.

**Every authored document cooks through one path.** Batch, watch and the model cook all call the same companion cook: the file is read once, an object that repeats a key is refused (serde would silently keep the last copy), the document is checked against its rendered schema, and the checked value goes to the kind's compiler, which returns bytes. That compiler is where the real work lives: resolving names to indices, flattening graphs, lowering particle modules to IR, and packing fixed tables so the runtime reads them without allocating or validating. The output write is content-compared and atomic, so an unchanged recook leaves the file and its mtime alone and the engine never reads a half-written file. Format modules do not read or write authoring files themselves.

Model import preserves hand-authored model documents and their bindings. Generated and hand-authored model documents use the same companion compiler; part transparency follows the cooked material at load. Existing binaries never suppress an authored-document recook.

The runtime never falls back from a cooked file to its authoring document: every kind is read from its cooked form only, and a recook in flight simply means the previous cooked bytes are still what the store serves.

**Authoring materials / model docs** (`.material.json` → `.shinmaterial`, `.model.json` → `.shinmodeldoc`) are not plugin work items. On initial `process_all` (watch mode start, including `kaji editor … --clean --run`):

1. They cook **before** the parallel model/texture pass so the live work tree has materials while the editor still binds the project scene (game-module compile splash often overlaps Shinra).
2. Each successful cook emits a terminal event for **both** the authoring JSON key and the cooked binary: `available` on a byte-identical cache hit, otherwise `rebuilt`. Soft-miss / pink error stand-ins upgrade without a full scene reload.
3. `@asset catalog` at end of the pass also triggers a soft rebind pass (`notifyAssetsMayBeReady`) so any residual stand-ins retry once the work tree is complete.

Graph-authored materials add an offline lowering step to that same material
path. Shinra checks material documents against their rendered schema and
lowers those carrying a `com.hikari.material-graph` payload. Source errors are
accumulated independently, so valid graphs and unrelated assets still cook.
It then validates node ids, slots, types,
sampler-slot uniqueness and acyclicity, and writes generated Akari plus a
target-specific transient spec under `--shader-cache-dir/material-graphs/`.
Generated source is cache output, never project source; sources whose material
was deleted or renamed are pruned on every pass. Batch builds merge the
transient spec with the explicit project/engine specs.

Watch mode is scoped twice, and both matter for editor latency. A graph save
that lowers to byte-identical Akari compiles **nothing** — every node drag is
one of these, because positions live only in the authoring document and never
reach the shader. When the lowering does differ, only the generated spec is
recompiled: the engine and game specs cannot have changed, and compiling the
merged set meant one compiler process per spec per debounced keystroke.
Successful native libraries publish atomically; an invalid graph leaves the
last-known-good library live and fails only that material source.

## Soft refs and load/hot-reload recovery

Content references (mesh, material, texture, shader, script, archetype) are **soft**: a missing or failed asset must not crash the process or abort the whole scene. Authored paths stay in the scene document; runtime binds best-effort and recovers when the asset reappears.

Shader cooks follow the same rule per package. Shinra publishes every library
Akari produced even when another package fails, and live watch retains the
failed package's last-known-good library. Only the changed source is reported
failed; unrelated shader-library keys are completed as `rebuilt`, so one bad
material cannot invalidate the renderer's engine libraries. A clean cook has no
previous binary for the failed package, so that package alone uses the normal
soft fallback until fixed.

**Shared runtime** (editor preview world and packaged game use the same path): `AssetStore` states, soft material retain, soft mesh bind, soft script attach, soft archetype spawn, pink error shader, skybox color fallback.

**Editor-only**: Shinra watch / hot-reload apply, hierarchy/inspector health UI, game-module Recompile + archetype audit, deferred apply while Play is active.

Implementation: `assets/ref_status.zig`, `ref_log.zig` (once-per-issue logs), `ref_consumers.zig`, `editor/actor_health/diagnostics.zig`, `error_mesh.zig`, `material_cache.zig`, `scene/scene_loader.zig`, `editor/editor_app/hot_reload.zig`.

### Status model

| `AssetStore` / ref status | Meaning |
|---|---|
| `pending` | **In-flight or soft miss** — pipeline may still emit the file, bundle not retained, or first open hit `UnknownAssetPath` in loose/editor mode. **Not an error.** No warn log; mesh hidden until ready; texture slots empty with defaults. |
| `ready` | Opened / decoded successfully. |
| `failed` | Terminal: hard decode/parse or `@asset failed`. Sticky until `markPending` / rebuild / evict. **Warn once**; error cube / pink / hierarchy broken. |
| `missing` | Terminal catalog absence — packaged `UnknownAssetPath`, or `@asset removed`. Loose/editor open misses stay `pending` until a terminal event. **Warn once.** |

**Editor startup:** while Shinra is still processing the queue, absences are **pending**, not missing — console must not spam “texture/mesh missing”. Terminal only after `@asset failed` / `removed` or a hard decode failure.

`state_generation` bumps on state change so the editor can refresh health icons without thrashing every frame.

### Cause → result (by asset kind)

Each row is **cause → result**. Viewport/runtime behavior is the same in **editor** and **game** unless noted.

#### Mesh (`.shinmodel`)

The mesh path cooks unique vertices plus meshlet topology. Import normalizes non-indexed source primitives to an index list, `optimize_mesh` welds identical attribute vertices and removes degenerate triangles, then SRM1 v5 writes vertices, 64-byte meshlet descriptors, remaps, and packed micro-triangles — no index buffer on disk. Hikari converts each unique vertex once into the engine layout, materializes a u32 index stream from the meshlets for BLAS and physics only, concatenates per-LOD slices, and uploads the meshlet topology used by every scene-mesh raster lane. Static CPU staging is released after the GPU snapshot unless the model explicitly sets `allow_cpu_access`; render residency never creates a triangle-corner copy.

| Cause | Result |
|---|---|
| Mesh still processing / not on disk yet (loose) / soft not-ready | Store **pending**. Actor stays; geometry not bound; **hidden**. **No warn log.** |
| Hard decode fail, or `@asset failed` / `removed` | Store **failed**. Magenta **error cube**; once **warn**. Hierarchy broken (editor). |
| Mesh rebuilt (`@asset rebuilt`) after fail | Store ready → consumers re-instantiated → real mesh; once-log cache cleared for path. |
| Hot reload of mesh with actor refs | Terminal event + refs → full scene re-instantiate from document UTF-8 (edit view preserved). |

**Editor:** hierarchy destructive only when mesh is **failed** (not while pending).  
**Game:** same viewport; no hierarchy/inspector chrome.

#### Material (`.material.json`)

| Cause | Result |
|---|---|
| Material JSON missing, unreadable, or root unset | Soft retain installs pink engine stand-in (`MaterialCache` error entry, package `_engine/error_material`). Scene load continues. |
| Material JSON ok but project shader package unavailable | Renderer loads pink `error_material` for that material (see shaders). |
| Material rebuilt / file restored | `reloadForAssetChange` swaps the live material; error stand-in dropped when the entry reloads. **No** full scene re-instantiate. |
| Hot reload of material with actor refs | In-place rebind under render-thread pause (same family as shaders). |

**Editor / game:** same soft retain and pink stand-in.  
**Editor only:** health banner + hierarchy warning when material is the error stand-in or path is missing.

#### Shader (compiled package / library)

| Cause | Result |
|---|---|
| Project shader not finished / file absent during load | Pink error shader (Metal/D3D12 fallback). The stand-in preserves the authored G-buffer, direct-forward, or weighted-OIT attachment contract. Expected during pipeline churn. |
| Shader compile fail → `@asset failed` | Store `failed`; materials reload in place and render pink. Once-log at err for the path. |
| Shader `@asset removed` | Same as failed for dependents. |
| Engine package (`_engine/…`) fails to load | **Hard** failure for that material load path (engine assets are required). |
| Shader rebuilt | Ready + in-place material/pipeline rebind. |

**Editor:** pipeline events drive store + in-place material/pipeline rebind; console gets once-logs.  
**Game:** no live shader recompile loop; missing packaged shader → pink at first material load.  
**Log noise:** successful pink fallback for *pending* packages is often `debug` (see config section).

#### Texture (`.shintexture`)

| Cause | Result |
|---|---|
| Texture not processed yet / soft miss (`pending`) | Slot null; pass defaults (white/normal/black). The material slot remains a residency-ticket requirement, including for actors spawned during Play. **No warn log.** |
| Texture hard fail or `@asset failed` / `removed` | Slot null; once **warn**; materials stay otherwise ready. |
| Texture rebuilt | Live rebind (`MaterialCache.refreshTextureAsset`) + residency update + GPU `ObjectData.map_mask` upload on next render sync — **no** full scene re-instantiate. |
| Skybox cubemap pending/fail | Solid skybox color (hot-reload safe). |

**Editor / game:** same bind + fallback textures.  
**Editor:** hierarchy broken only for **failed** slots, not pending in-flight.

#### Script (`.kawa` → `.kawabc`)

| Cause | Result |
|---|---|
| Bytecode not ready / soft miss | Entity spawns **without** script; soft attach skips. |
| Bytecode hard fail or load/run error | Entity stays scriptless; hard attach warns once (path-based). |
| Script rebuilt with actor refs | Scene re-instantiate → soft attach when bytecode available. |

**Editor / game:** same soft attach.  
**Editor only:** live Shinra recompiles scripts; health can show script pending/broken.

#### Zig archetype (game entity type)

| Cause | Result |
|---|---|
| Scene references archetype not in registry | Spawn **`_missing`** host (transform + render, error cube, pink-ish material path). Scene continues. |
| Recompile removes a type still in the open scene | Soft placeholders for those actors; audit logs missing types after successful recompile. |
| Recompile **build** fails | Keep previous dylib; no scene swap. |
| Recompile succeeds but register/load fails | Restore previous game module + scene; **panic only if restore itself fails**. |
| Recompile drops or renames a script native | Game-owned natives are forgotten with the module (`clearGameNatives` + trampoline `clearSlots`); the VM is rebuilt so no binding points into the old dylib. |
| Game left a job group open across the swap | `job_groups.joinAll()` waits for it before `onTerminate`; a non-zero count is logged as a game bug. |
| Old dylib stays mapped after close (Objective-C / TLS image, e.g. MetalFX plugin) | Warned once per session (`isImageResident` probe). Each Recompile then leaks the module and stale pointers into it stay silent — treat "no crash after reload" as no evidence. |
| Editor quits mid-compile | The child `zig build` is killed (`BuildCancel`); quit does not wait for the compile. |
| Host/game **ABI or Entity/World layout** mismatch | Reject new dylib; keep previous; log “fully rebuild editor”. |

**Editor (either host linkage mode):** Recompile button + staged dylib + restore path. The compile and the swap each log their wall-clock time (`game module gen-N built in … ms` from the frontend, `game module swap took … ms` from the session under the `game.module_swap` profiler zone). Frame pacing drops to the idle cap while the compile runs so the editor does not compete with `zig build` for cores.
**Game:** no recompile; unknown archetype at load still soft-spawns `_missing` if builtins registered (engine always registers core placeables `empty` / `light` / `camera` / `visual_zone` plus soft hosts `_missing` / `_asset` / `_script` / `_audio`).

### Hot-reload pipeline edge cases (editor)

| Cause | Result |
|---|---|
| Initial process-all / file not written yet | Open → **pending** (not missing). Quiet; soft bind. |
| `@asset pending` | Store pending; log **info** with ref count; **no** scene re-instantiate yet; **no** missing/failed warn. |
| `@asset rebuilt` | Evict that `ReloadFamily` + stem; mark ready. Texture / material / shader rebind in place. Mesh / model / script **with authored actor refs** → full scene re-instantiate from document UTF-8. Prefab / particle / animgraph / audio / font evict and rebind holders; they do not by themselves count as scene consumers. |
| `@asset failed` / `removed` | Mark **failed**; same rebind vs re-instantiate split as rebuilt; once-log **err/warn**; hierarchy/inspector broken via `state_generation`. |
| In-flight → failed | Prior quiet pending; then terminal failed + warn + error cube / empty texture as above. |
| In-flight → rebuilt | Quiet pending → ready; mesh/texture appear without ever logging “missing”. |
| Cook-cache hit → available | Ready edge only; byte-identical resident assets are not evicted, decoded, or rebound. |
| Asset event while **Play** active | Deferred (`editor_state` asset_reload work); applied after Stop / when allowed. |
| Shinra busy + many events | Coalesced per path; status bar shows queued / processing. |
| Dropped notifications (queue overflow) | Warn with drop count; may miss until next rebuild-all / resave. |
| Texture / material / shader batch | Rebind under render-thread pause; no full scene thrash. |
| Mesh / model / script with authored actor refs | Serialize document UTF-8 → `hotReloadAssets` → re-instantiate (edit camera preserved). |
| Catalog / disconnect | Asset browser refresh; also retries soft-miss materials/meshes (`notifyAssetsMayBeReady`). No automatic full scene reload unless paths also fail. |

**Game:** no Shinra watch daemon; assets are load-time only from bundle/loose config. No deferred Play queue.

### Cold load vs hot reload

| Situation | Result |
|---|---|
| Startup scene with mixed good/broken refs | Scene loads; broken actors use soft placeholders; good actors normal. |
| Corrupt / invalid **scene JSON** schema | Hard fail for that load op (not a soft content ref). |
| OOM during load | Hard fail (unrecoverable setup). |
| Play → Stop | Immediate switch to the retained **editor world**. The disposable Play world is retired afterward in bounded slices; Stop does not decode or instantiate the authored scene again. See [Session services — Play-world retirement](/docs/systems/session-services.md#play-world-retirement-engine). |

### Editor vs game (summary)

| Concern | Editor | Packaged game |
|---|---|---|
| Asset source | Loose processed tree + live Shinra | Packaged bundles / staged data (typical) |
| Live reprocess | Yes (`@asset …`) | No |
| Hierarchy / inspector health | Yes (destructive styling + issue banner) | No chrome |
| Soft mesh / material / texture / script / archetype | Yes | Yes (same engine code) |
| Pink error shader / error cube | Yes | Yes |
| Game Zig recompile | Both host linkage modes | N/A |
| Apply while simulating | Asset reload deferred in Play | N/A |

### Debug vs release config

Soft **behavior** (placeholders, continue scene, no crash on content) is the same in debug and release builds.

| Concern | Debug (`--config=debug`) | Release (`--config=release`) |
|---|---|---|
| Soft bind / pink / error cube | Same | Same |
| Log level (frontends) | Typically `debug` → pending/pipeline chatter and pink-fallback debug lines visible | Typically `info`+ → once **warn/err** still visible; many pending/debug lines suppressed |
| Once-log for failed/removed | warn/err | warn/err |
| Shinra tool build | Release by default even under product debug (`--shinra:config=debug` to override) | Same default |
| Shader compile (Akari) | Debug by default; override with `--shaders:config=release` | Release by default; override with `--shaders:config=debug` |
| Leak-check smoke | `--memory-leak-check` + `--run` useful after hot reload / Play→Stop | Optional CI; not product default |

There is **no** separate “strict fail content in release” mode: shipping builds still soft-bind so a bad asset does not take down the process. Fix content and rebuild packages for clean shipping data.

### Recovery cheatsheet

| After… | Recovery |
|---|---|
| Asset reappears / rebuilds | Ready event → rebind or re-instantiate → clear once-log for path |
| Material/shader fixed | Reload consumers (hot reload or Stop/Play or scene reload) |
| Archetype restored in game code | Recompile (editor) or restart game with new binary |
| Sticky `failed` without event | Rebuild asset / `rebuild-all` / re-open project so store gets `markPending`/`ready` |

## Asset authoring rules

- Keep raw assets and sidecars in the game package; do not write generated assets into engine source folders.
- Treat Shinra output and bundles as generated artifacts.
- Keep platform-specific compression in Shinra configuration, not in runtime asset consumers.
- Validate output after format or bundle changes.
- Use the engine's checked-in shader packages for required renderer passes; add game packages only for game materials/effects.

### CPU-readable texture imports

Texture sidecars accept `texture.uncompressed: true` to retain lossless RGBA8 in the cooked `.shintexture` on every target. Compression remains enabled by default. For native/software cursor images, also disable mip generation and premultiplied alpha; see [Game cursors](/docs/guides/ui-cursors.md). The authored PNG is the only image source; cook output and thumbnails remain pipeline-owned.
