# Editor asset hot reload

The editor implements live visual asset swapping through a thread-safe, daemon-to-edit event queue and a render-thread-paused re-instantiation path. This page describes the current flow and the invariants that keep it safe.

The queue separates background cooking from world mutation. GPU-visible changes
happen only inside the frame-boundary pause:

```mermaid
sequenceDiagram
    participant D as Shinra
    participant E as Editor / session
    participant R as Renderer
    D->>E: Queue asset events
    Note over E: Drain in Edit only<br/>Play keeps events queued
    E->>R: Pause at frame boundary
    R-->>E: Paused
    Note over E: Release holds → evict<br/>Rebind → flush GPU releases
    Note over E: Queue scene replacement<br/>only if reconstruction is needed
    E->>R: Resume
```

## Daemon-to-editor event queue

- `editor/asset_pipeline/daemon.zig` runs Shinra in watch mode and emits `Event` values: `pending`, `available`, `rebuilt`, `failed`, `removed`, `catalog`, `disconnect`.
- `editor/editor_app/app.zig` owns the `asset_pipeline_daemon` and receives events through `onAssetPipelineEvent`, which pushes them into `EditorApp.asset_events`.
- `editor/editor_app/asset_events.zig` defines the `Queue`: path-coalesced, capacity-bounded (`max_paths = 8192`), and generation-aware. It keeps the last terminal state per path and ignores stale generations. `catalog` and `disconnect` are flags, not per-path events.
- Pipeline callbacks wake the durable main-thread bus so backgrounded editors can drain events. During Play, reload work is deferred: the durable pump parks the non-empty queue without re-waking, and the editor-state coordinator releases it when the mode returns to Edit.

## Editor tick processing

- `editor/editor_app/hot_reload.zig:processPending` runs each edit tick when `editor_state.allows(.asset_reload)`.
- It drains `asset_events` into `asset_event_drain` using `Queue.drainBoundedInto`, up to 48 distinct paths per tick without splitting a stem.
- If events were dropped, it requests a rebuild-all from the daemon **once per overflow burst** (debounced until the daemon has gone busy→idle, or a `catalog`/`disconnect` arrives). Stacking rebuild-all while the queue is still overflowing re-floods Shinra.
- `asset_reload_refs.collect` counts affected actors for the entire batch in one document walk. It indexes event stems, matches script, geometry, surface-material and fog-material references, and resolves shader dependencies once per distinct surface material. Primary and per-slot materials share the same memo. An actor counts once per event even when several fields reference it.
- For each per-path event it:
  - classifies the path as texture / material / shader / mesh / script;
  - decides whether a full scene reload is needed (`requiresSceneReload` is **false** only for texture / material / shader);
  - invalidates the editor thumbnail cache for that path;
  - tracks `.shinparticle` rebuilds for preview rebind;
  - builds a `Session.AssetChange` list.
- Scene rebuild requests coalesce until the cook and any existing scene load settle. They remain deferred during Play and boot module compilation. The editor rechecks consumers before serializing the authored `SceneDocument`; a later scene load can supersede a deferred request.
- It calls `session.hotReloadAssets(applied, scene)`.

## Runtime hot reload

- The editor drains reload events in Edit. `runtime_session/scene_ops/hot_reload.zig:hotReloadAssets` updates owned-world holders and live-world render queues.
- It pauses the render thread with `session.render_thread.pauseAtFrameBoundary()` and resumes it in a `defer` with `resumeFromFrameBoundary()`.
- For each change:
  - it deduplicates by `asset_deps.ReloadFamily` + stem (`texture` / `model` / `audio` / `font` / `blob`);
  - stops audio streamers before eviction for `.audio` families;
  - calls `forceEvictFamily(world, store, stem, family)`, which abandons world holders (skybox, visual-zone LUTs, bound meshes, animation skeleton/clip binds, material texture slots; marks animation-graph actors stale) then calls `store.evictFamilyForReload(stem, family)`. Abandoning **releases** each hold rather than nulling it: eviction defers to the last borrower, so a hold dropped without its release strands the entry `reload_pending` and the reload never arrives;
  - if the store returns a GPU texture handle, enqueues `RenderCommand.release_texture` on `world.render_command_queue`;
  - updates the `AssetStore` state to `pending`, `failed`, or `ready`;
  - refreshes texture bindings in `world.materials` for texture assets;
  - reloads materials/shaders via `world.materials.reloadForAssetChange` and patches primitive references;
  - invalidates compiled Kawa scripts for `.kawa`/`.kawabc` changes;
  - re-arms looping audio beds for `.audio` changes.
- After all changes it drains pending texture frees, flushes the render command queue, and (if a scene snapshot was provided) queues `requestSceneReplaceFromUtf8(session, session.scene_path, scene, .preserve)`.

## Render-thread synchronization

- `graphics/renderer/render_thread.zig` implements `pauseAtFrameBoundary`/`resumeFromFrameBoundary` using `pause_requested` and `paused` flags protected by the existing `mutex`/`cond`. Pauses nest: `pause_depth` counts host-side holders and only the outermost resume lets the thread run.
- `graphics/renderer/render_command_queue.zig` has a `release_texture: RenderHandle` command variant.
- `graphics/renderer/renderer_shared/commands.zig:applyRenderCommands` drains `release_texture` via `resources.deferRemoveTexture(handle)`, which ages the GPU resource before freeing.

## Scene reload behavior

- Full scene re-instantiation runs only when `requiresSceneReload` is true **and** the batch’s affected-actor count is positive (authored `script` / `model` / `mesh` / `material`, or a material that references a changed shader). The `SceneDocument` is serialized to UTF-8 and queued through `runtime_session/scene_ops/load.zig:requestSceneReplaceFromUtf8` with `EditViewSeed.preserve`.
- `requestSceneReplaceFromUtf8` queues a budgeted replace `SceneLoadJob`; the outgoing scene remains visible until the replacement commits. `World.unloadScene` / `unloadSceneForSwap` do **not** clear `render_command_queue`, so `release_primitive` commands emitted during entity teardown are preserved and drained before the new scene renders.
- Texture, material, and shader changes rebind in place. Prefab / particle / animgraph / audio / font evict and rebind holders but do not contribute authored geometry/script/material references, so they do not by themselves serialize the document.

## First cook is not a change

Content hashes are tracked by full logical path, so sibling artifacts cannot
inherit one another’s change state. First arrivals and byte-identical rebuilds
do not request a scene replacement. Changed bytes request one only when live
consumers still hold the old content. Stub worlds and worlds still loading use
the existing load and residency wake.

Without a content hash, `firstCookBindsInPlace` uses the consumer’s pending intent
and store state:

| Case | Reload? | Why |
|---|---|---|
| mesh/script, `.pending` → `.rebuilt` | no | pending records bind the arrival in place |
| mesh with pending or matching bound parts | no | consumer state overrides shared-stem store state |
| mesh/script, previously `.ready` or `.failed` | yes | no pending record will rebind changed content |
| prefab | yes | expansion happens at spawn without retained pending intent |

Authoring JSON mirrors (`*.material.json`, `*.model.json`, …) publish as
`available`, not `rebuilt`: the cooked binary is the runtime change, and treating
the JSON copy as a change would evict and re-parse the same material twice per cook.

Fog-volume materials parse through `loadDesc`, which pins the blob. Five
lanterns sharing `lantern_glow` decode once. They cannot `MaterialCache.retain`
— that path rejects non-surface domains and would install a pink stand-in.

Engine shader acquire pins the blob after the first decode, so PSOs sharing one
metallib do not re-read it on every acquire/release.

Cook progress logs one line per source (relative path, duration). Extracted
companions with no actor field still hot-reload; they log at debug, with an
info summary when a batch has eight or more.

## Invariants for maintainers

- Route asset change notifications through the path-coalesced queue; do not call `hotReloadAssets` directly from daemon threads.
- A durable pump may re-wake only for work that is runnable now. Play-deferred asset events stay queued until the Edit transition publishes `work_ready`; treating a merely non-empty queue as runnable can starve the native message loop.
- Pause the render thread at a frame boundary before evicting or reloading assets; resume only after flushing the render command queue.
- Preserve the render command queue across scene unload/load so GPU release commands are not lost.
- Force-evict by `ReloadFamily` only; a same stem with a different kind (for example, texture vs model) must not be wiped by a single event.
- Abandon world holders before `AssetStore` eviction to avoid use-after-free on same-stem rebind.
