# UI and editor

Scene opening in **Edit mode** presents progressively, including replacement loads: geometry uses the pink error material while native material pipelines compile, then adopts the finished pipelines between frames. Play and standalone scene loads retain their requested `immediate`, `gated`, `deadline`, or `manual` presentation policy. Pending native pipelines count toward GPU readiness; a drawable placeholder does not open a gated or manual scene.

Game UI authoring: [User interface](/docs/guides/user-interface.md) · [layout](/docs/guides/ui-layout.md) · [widgets](/docs/guides/ui-widgets.md). This page covers the **editor host** and how game UI composes with it.

## Runtime UI (host summary)

Immediate-mode foundation in `src/hikari/src/ui/`. Games build through `hi.ui()` / `world.ui`; compositor + provider flatten to one atlas draw per frame. **Separate palettes:** runtime `Theme.runtime()` vs editor `Theme.dark()` / `Theme.light()` — never copy editor theme onto `world.ui`. Viewport remaps the game stream via `ViewportUiLayer` without changing the game theme.

Runtime and retained editor UI share lifecycle-neutral mechanics through `ui/foundation/`: geometry and affine transforms, routed input values, UTF-8 text edits, scalar range mapping/quantization, pointer/drag phases, stable-id selection, viewport math, virtualization windows, outside-press semantics, and popup placement. They deliberately keep separate storage, layout, paint, binding, transaction, and component layers.

## Editor UI

Retained chrome in `src/hikari/src/editor/ui/`: stable generational widget IDs, measure/arrange/paint, the same compositor/provider contract as runtime UI. It is not a replaceable driver. Runtime and retained UI share lifecycle-neutral mechanics through `ui/foundation/` (geometry, routed input, UTF-8 text edits, range mapping, pointer/drag phases, stable-id selection, virtualization windows, outside-press semantics, popup placement) and deliberately keep separate storage, layout, paint, binding, transaction, and component layers.

### Tool-window presentation

Stateful first-party tool windows follow one model-view-presenter pattern (`editor/presentation.zig`):

- The **model** owns editable data and must be usable without a retained tree. `presentation.Surface` / `assertModel` reject `RetainedUi` and `WidgetId` fields at compile time.
- The **view** owns widget IDs and custom elements and exposes passive `push` / `pull`; it never saves files, mutates the world, or decides policy.
- The outer tool type is the **presenter**: it interprets view events, pulls edits into the model, invokes stores or editor services, and pushes state back. `presentation.Lifecycle` supplies shared open/close and initial-input guards.

Binding is explicit at the retained boundary (`presentation.Text` + `TextField`, `ui/property_binding.zig` for booleans, choices, scalars), so rebuilding or detaching a view cannot discard edits. First-party window policy (title, sizing, flags, surface kind, cardinality) is one keyed `Spec` entry in `editor_app/tool_windows/spec.zig`, compile-checked for coverage; live dialog instances belong to their `EditorUiSurface`, not to global `EditorApp` fields. Complex Asset Detail kinds (Material, Animation Graph, Particle, UI Document) use the same `state.model` / `state.view` split and must register through `editableController` / `editableControllerFor`, which run `presentation.assertPresenter`. `asset_detail/registry.zig` is the single keyed table for controller routing and read-only/authoring policy, one row per asset `Kind`.

### Retained toolkit

- **Elements.** Complex controls extend the tree through `retained.Element` (measure, arrange, paint, hit-test, routed events, semantics) and `retained.CustomLayout` for child placement. Both participate in dirty propagation and paint caching; neither may open a second provider or input loop. Pointer events route capture → target → bubble with explicit capture. Affine transforms compose across ancestry for hit testing, clipped paint caches, and semantics. Portaled nodes register a logical owner so owner teardown removes the portal and restores focus.
- **Stacks and animation.** Retained stacks expose flex grow/shrink, layout priority, and aspect policy; toolkit custom layouts provide grid/table/canvas/overlay tracks. `RetainedUi.animate` owns interruptible style/layout channels with reduce-motion substitution.
- **Inspection.** `RetainedUi.diagnostics()`, `visitDebugNodes()`, `inspectorOverlay()`, `visitSemantics()` are allocation-free. The **Debug Tool Window** shows live tree identity, bounds, dirty state, last invalidation cause, paint cache misses, focus/hot state, and the latest route; **Show hidden** includes hidden subtrees. Stable automation ids resolve through the semantic tree into the same dispatcher native input uses.
- **Capacity.** Tree topology owns traversal capacity: structural mutation may grow slots and caches, but stable input, hit testing, layout, repaint, and composition frames do not allocate. The retained test suite enforces this with an allocator that rejects everything after cold layout.
- **Accessibility.** The tree is published per native window after layout (NSAccessibility on macOS, UI Automation on Windows). Native callbacks enter bounded per-window action queues drained on the next frame. Publishing is gated on a client being attached (`hikariAccessibilityWanted`) and on `RetainedUi.semantics_dirty`.
- **Text entry.** `NSTextInputClient` and IMM32 translate native UTF-16 ranges into UTF-8 byte ranges and preserve marked text; runtime and retained fields apply the same `text_edit` composition replacement, with retained provisional updates collapsing into one undo step on commit.
- **Compile splash.** One-shot retained chrome, constructed only when startup has a game build pending and destroyed after the exit animation; later recompiles use toolbar/toast feedback.
- **Change detection is a revision, not a re-hash.** Consumers (viewport remap skip, chrome underlay compose, world static-publish skip) ask the provider for a `FrameRevision` (`owner` + monotonic `revision`) via `Provider.frameChangeToken`; layers that rewrite output (`ViewportUiLayer`, `ViewportOverlay`, `RetainedUi`) bump a local generation. An immediate `UiContext` fingerprints once per frame and memoes.

### Graph canvases and node editors

The animation graph and material graph share one domain-neutral graph element that paints into the retained cache and consumes routed pointer events. Cards carry a category chip, pin names beside sockets, and an optional value footer; the domain supplies glyphs through `graph.GlyphFn` and sizes cards through `graph.metrics.naturalHeight`. Text scales with zoom and greeks below `metrics.readable_scale`. Edge arrowheads are drawn only for `.state` edges.

Canvas navigation is one policy (`foundation/gesture.zig: canvasIntent`): pinch zooms; ⌘/Ctrl+scroll zooms; a precise (trackpad) sample pans on both axes while a detented wheel zooms; Space+primary-drag and secondary-drag pan. Because secondary drag pans, node search opens on a stationary secondary *release* judged against `ui.click`'s tap slop. `MouseFrame` carries `wheel_x` and `pinch` beside `wheel_y` for this. The element provides stable-id multi-selection, marquee, manipulation sessions, alignment guides, group drag, and one undo scope per gesture; the timeline ruler, dope sheet, and particle curve/gradient canvases use the same seam.

### Asset browser

The browser is one virtual-grid leaf that paints only visible cells, with thumbnail-tile and compact-list layouts behind one interaction path (choice persisted per project). Cards are quiet at rest: only hover, press, and selection paint. The animation graph's clip library reuses the same leaf.

The browser edits the project. Right-click menus come from one table (`asset_browser/actions.zig`: New…, New Folder…, Import…, Rename, Duplicate, Delete); drag moves; F2 / forward-delete act on the highlighted tile. The browser emits a `Request`; `editor_app/asset_flow/manage.zig` gathers what is missing and performs the work through `editor/asset_ops.zig`, which the MCP `asset_move` tool also uses. A rename or move rewrites every reference in the project from the slot table in `editor/asset_refs.zig`, where slots are classified by key *and kind* (a generated companion material shares its model's stem). The open document is repointed in memory slot by slot (`SceneDocument.rewriteAssetRefs`, non-undoable): materials, cues, graphs, particles, and decal maps update live through `component_field`; geometry, collision, and prefab links mark the deferred whole-scene rebuild the cooker's events use, so the viewport rebuilds once after the moved asset has cooked under its new name. Delete counts and names referencing documents first and offers a same-kind replacement. Import is the native file dialog filtered by the `source_extensions` table plus a destination/name wizard.

### Material detail

Material detail distinguishes documents by payload: a `graph.kind: com.hikari.material-graph` payload opens the graph surface (toolbox drawer, canvas right-click search, Class 2 preview on a unit plane); a plain `.material.json` is a hand-authored Akari material, previewed and deliberately not converted; a document that claims a graph and will not load says so. Node kinds are one comptime table (`editor/material_graph/types.zig`) shared with Shinra through `nodes.schema.json`; sampler nodes are one per material texture slot and a second is refused with a reason. The node drawer is a `searchField` over `chrome.section`s in `material_graph.types.category_order`; a live filter forces sections open without touching the author's collapse state.

The window is split as `kinds/material/{types,state,chrome,interaction,preview,edits}` and shares the animation graph's undo model: whole-document snapshots on `editor/history.zig`, coalesced per gesture, published as `undo.Target`. Graph edits debounce to the source document; Shinra rebuild events refresh the preview only when the *semantic* revision moved, so arranging nodes does not rebuild the plane. A bottom diagnostics console on the shared `components/console.zig` grammar carries **validation** rows (recomputed from the document as the semantic revision moves, clickable to select and frame the node) and **pipeline** rows from `asset_pipeline_daemon.diagnostics()` (where an Akari diagnostic with a line number lives), tagged by producer because a `Graph` row changes on the next keystroke and a `Shader` row on the next cook. This is not `editor_app/console_ui.zig`, the global log panel. A **Custom Expression** node inlines an Akari expression (inputs `a`, `b`, `uv`, `time`); the expression-only rule is stated in `material_graph/expression.zig` (editor feedback) and Shinra's `expression.rs` (authority). `MaterialGraphVertex` carries `worldPos` because the tangent frame derives from its derivatives and Fresnel needs it for the view vector, which is parity with the standard `gbuffer` varying.

Detail-window bands are sized, not measured: the animation graph's inspector band is a fixed-height strip whose frames hold still while contents swap, so a selection change never moves the canvas.

### Visual language

shadcn-derived and deliberately quiet: a neutral zinc elevation ramp (`canvas` → `surface_sunken` → `surface` → `surface_raised`), one **hairline** `border` token for control outlines and dock seams, a 4/6/8 corner-radius ramp, flat fills. Colour is rationed: core blue means CTA, selection, or focus and nothing else; photon rose is the rare brand flash.

`editor/ui/components/surfaces.zig` holds the named recipes (`card`, `well`, `popup` / `popupClosed`, `modalCard`, `scrim`, `row`, `chip`, `pill`, `accentPlate`, `notice`, `hud`). Ask for a shape by name; do not hand-write fill + outline + radius in a panel. Each recipe takes a `Fit` patch for the parts the caller owns (size, padding, interactivity).

```zig
const card = try retained.panel(parent, surfaces.card(retained, .{ .min_height = 52 }));
const input = try retained.textField(row, .placeholder, surfaces.well(retained, .{ .focusable = true }));
```

Rules when adding chrome:

- **A control never outlines inside another control's outline.** One hairline around a set, none on its members (`toolGroup` outlined, `groupedToolButton` not; segmented track outlined, selected segment not). Containers may hold bordered inputs. Pinned by `components/tests.zig`.
- **Outline or fill, not both.** `normal` buttons, dropdown triggers, inputs, and cards take the hairline; filled variants (`accent`, `toggled`, `destructive`, transport) do not.
- **`accent` is for exclusive modes and primary actions only.** An independent on/off switch uses `ToolButtonVariant.toggled` (`accent_muted` fill, accent ink).
- **Menu and list rows are borderless.** The popup owns the outline; rows show state through hover tint and an `accent_muted` wash.
- **Tabs are handles, not pills.** `tabStrip` is a recessed `canvas` rail with no bottom seam; the seated handle carries the pane's own `surface` fill so tab and content meet with nothing between them, with the accent→brand sweep on its top edge as the seated marker.
- **Radii come from tokens** (`radius_sm/md/lg` = 4/6/8, `surfaces.modal_radius` = 12, 999 for pills). `WidgetStyle.corner_radii` overrides corners independently and every layer follows it.
- **Inputs sit in `surface_sunken`**, not `canvas`.
- Focus is the web `:focus-visible` shape: a translucent 3pt ring plus a full-strength hairline on the control edge.

### Bottom console and notifications

The editor console (`editor/editor_app/diagnostics.zig`) is a UI sink on `log.register`; `log.zig` still tees to stderr and, with a project open, to `<project>/.engine/logs/editor-latest.json`. The sink's write path is allocation-free by contract (rows bump into budgets reserved at init), because a log record can originate from inside an allocator's own lock. Rows are narrowed by one predicate (`diagnostics.Filter`: exclusive severity segmented control plus a substring search), shared by counting and row lookup.

The console records everything and surfaces nothing, so `editor/ui/toasts.zig` is the noticing half: a bounded stack of cards over the bottom-right corner. **Never push a toast directly**; call sites go through `editor/editor_app/notify.zig` (`err` / `warn` / `info` / `success`), which writes the console record and the card from one call (`<title>: <detail>` under scope `editor`). Toasting every `.err` log record is deliberately not done. Guarantees: four cards built once at startup with inline text buffers (no allocator on a failure path); the fifth arrival retires the oldest and identical consecutive messages collapse into one card with a count; severity is shape first, colour second, with lifetimes 11 s / 7 s / 4 s; the host is hidden when empty (so it cannot swallow viewport navigation via `sceneInputBlocked`); a card may carry one bounded action (screenshot completion reveals the file in Finder/Explorer); it is the last root child so it paints above modals.

### Reusable components

`editor/ui/components.zig` owns the shared chrome: toolbar, menu bar, tab strip, dock panel, tool group/button, separator, status pill, checkbox, segmented control, dropdown, progress, color field, context menu, `components.value_rows` (labeled scalar / XYZ / min-max rows binding caller-owned floats, the grammar of every typed inspector pane), plus `components.surfaces`. Retained also exposes `tooltip`, `beginDrag` / `acceptDrop`, scrollbar thumbs, and label `wrap_text`. Alignment enums: retained `Align` = `ui.CrossAlign`, `Distribution` = `ui.Justify`.

- **Search is one component** (`components/search_field.zig`): well, magnifier, field, clear button. Callers keep the query in their own model; `syncClear` is told the query and `clearPressed` reports the press. The command palette deliberately does not use it (its card is the input).
- **Playback is one component** (`components/transport.zig`): play/pause plus opt-in stop / restart / loop, timecode, and scrub timeline, as a view over the caller's clock (`updateTransport(State) → Action`). "Nothing to show" is `chrome.emptyState`.
- **The scrub timeline is one element** (`components/timeline.zig`): ruler, rail, playhead, and knob from one `Metrics.x` with the pointer read back through `ratioAt`; press to jump, drag to scrub, `Esc` cancels, `←`/`→` step, `Home`/`End`. Time arrives as a `ScalarBinding` and a drag is one transaction.
- **Color field** opens a scrollable picker (saturation/value plane, hue strip, opacity strip) with RGB float, RGB 255, HSV, HSL, and hex views of one bound colour; float RGB and HSV preserve HDR range, the others clamp to SDR. Numeric inputs forward begin/write/commit/cancel to the original bindings so a drag stays one undo step. Done or an outside press accepts; Revert or Escape restores.
- **Dock tab strips** keep intrinsic widths and pan horizontally when narrow (retained `scroll_x`). Tabs drag between the three docks and reorder within a strip (`toolkit/docking.DragSession`, `RetainedUi.reparent`, `.engine/user/docks.json`). A tab dragged clear of every dock **detaches** into a Class 1 tool window (`editor_app/panel_float.zig`) with a dock-back control; detach *rebuilds* the panel through its registration (`Registry.unmount` → `chrome.buildPanel`) because widget ids do not cross `RetainedUi` trees. Builtin panel host wiring is one row each in `editor_app/panel_host.zig`. The editor never splits new docks.

### Window title bar

Frameless editor windows draw their own caption strip (`components/window_title_bar.zig`): traffic lights + title + drag on macOS, title + caption buttons on Windows, one 36pt strip on both so docked chrome lines up. The controls inside are the platform originals at native size and insets — these sit next to real OS windows, and "close enough" reads as fake.

- **macOS 27 traffic lights** are `components/traffic_light.zig`: 14pt Liquid Glass lenses on 23pt centres, 9pt in from the edge, one retained `Element` each (hit target and painter). A dot is a stack of analytic layers — tinted halo, three-stop body gradient, low bloom, a side rim whose weight follows cos²θ (light theme), top/bottom edge lights following sin²θ (dark theme), a pressed hairline, and the glyph. Every number is measured from AppKit (`tools/macos/window_buttons/traffic_light_probe.swift` renders the real buttons offline, `tools/macos/window_buttons/traffic_light_fit.swift` fits the model; each look lands under 2.5/255 mean error). States follow the OS: a non-key window is neutral until the pointer is over the cluster, which restores colours and shows all three glyphs; a press *lightens* the dot. Re-measure after a macOS update; never tune by eye.
- The compositor grew `addRoundedRamp` / `addRoundedBorderRamp` for this: a vertical colour ramp positioned in absolute points on the analytic rounded shape, so a ring clipped to bands carries a piecewise-linear alpha around the circle. Unsnapped by design — concentric layers must not round against each other.
- Windows caption buttons are the WinUI spec (46×32 hit strips, Segoe Fluent glyphs, `SubtleFill` hover, `#C42B1C` close) and do not change with activation.

### Hierarchy, toolbar, and inspector

- **Hierarchy search:** bare terms match name/id/archetype with AND; `t:` / `type:`, `n:` / `name:`, `id:`, and `is:` (`runtime`, `authored`, `broken`, `locked`, `pinned`, `root`, `child`) scope; quotes group, a leading `-` excludes. A live filter reveals matches and ancestors without overwriting collapse state.
- **Hierarchy menus:** right-click Focus / Add Child… / Rename / Copy / Paste as Child / Duplicate / Delete. **+** Create is a scrollable popup: **Create** (Empty / Light / Camera / Visual Zone / Decal / Fog Volume / Audio Source / Script), **Place…** (Model / Texture / Decal / Audio Clip / Script / Material), then **Game** (project archetypes). Soft `_asset` is not listed.
- **Decals:** Create Decal authors a thin projector (`scale {1, 1, 0.25}`) with a volume gizmo (`GizmoMask.decals`) that turns red with *No surface in volume*; dropping a texture onto geometry places a surface-aligned decal.
- **Toolbar:** transform tools plus **Local/World** (`X`) and **Snap** (`⇧S`). **Edit** menu: Copy / Paste / Paste as Child (`⌘C` / `⌘V` / `⇧⌘V`) on an editor-owned clipboard; Paste places at the camera look point. Multi-select rotates/scales about the primary pivot and fans field writes to peers with the component. Inspector **Add Component** / section remove attach or clear authored blocks with despawn+spawn live sync.
- **Hierarchy rows have two single seams:** content is resolved by `resolveDocActor` (document) or `resolveLiveEntity` (live world), and colours are decided once in `rows.applyRowVisuals`, called by both the bind pass and the selection pass. A state added to only one resolver renders on nobody; a second hand-written `setStyle` silently reverts the first.
- **Hierarchy order:** live layer roots follow the loaded layer's authored actor order in Edit and Play, independent of World storage swaps. Runtime spawns and later additions follow the authored roots; child rows follow the live parent/child links.
- **Hierarchy keyboard:** the virtualized list owns focus; ↑/↓ move and select, → expands, ← collapses, Escape releases. Disabled in Play via keyboard ownership.

### Settings windows

**Project Settings** uses the schema's `Apply` value as presentation: every non-live row carries a timing note (**Restart editor to apply**, **Next game launch · restart editor for Play**, **Applies on next package**), the footer reports the strongest pending boundary, and a non-live Save repeats it in a toast. Rendering and Quality are entirely live on Save through the renderer's frame-boundary config transaction. The Quality page is sectioned: Quality (preset) · Shadows (shadow quality, filter, contact shadows, shadowed lights per pixel) · Lighting and ray tracing (AO, GI, reflections, ray hit lighting, RT budget, local lights, environment) · Atmosphere and post · Textures and culling · Directional shadows · Frame governor. Every Rendering and Quality row declares the engine's own default, enforced by a test in `editor/project_settings/schema/fields.zig`. Gameplay Tags is a page here (see [Scenes and gameplay](/docs/systems/scenes-and-gameplay.md#gameplay-tags-catalog-only)); external tag writes are blocked while the window is open. Both settings dialogs share rail/page construction and search through `editor/ui/settings_dialog.zig` / `settings_search.zig`.

**Editor Settings** (File → Editor Settings…) opens as a Class 1 OS tool window (`editor/editor_app/tool_windows.zig`) with pages Appearance / Accessibility / Scrolling / MCP / MCP Audit: theme (Auto / Dark / Light, resolved through `platform/system_theme.zig` and repainted live on OS appearance change), density, interface/text size (`ui.ScaleLevel`), viewport idle encode (**Realtime** default / **Efficient**, see [Rendering](/docs/systems/rendering.md#contract-b--classical-deferred)), high contrast, always-show focus, reduce motion, scroll speed/bounce/overscroll, fade inactive scrollbars. Prefs live in `.engine/user/prefs.json` (`user_prefs.Data` / `ui.AccessibilityPrefs`) and apply live to retained chrome and the game `world.ui` stream. MCP pages are project-scoped ([Agent MCP](/docs/systems/ui-and-editor.md#agent-mcp-per-project)).

### Environment panel

The left-dock **Environment** panel reuses the shared transport and timeline for a transient 24-hour viewport clock. Nothing in it is a gate: a scrub, a Sunrise / Noon / Sunset / Midnight jump, or Play arms the override, and the toggle releases the viewport back to the scene's clock. It dispatches only to scene components implementing `onEnvironmentPreview`, never mutates the document, and is restored before a Play copy begins. Authoring workflow: [Time of day](/docs/guides/time-of-day.md#scene-authoring-and-editor-preview).

### World Partition panel

The left-dock **World Partition** panel (`editor/panels/partition_panel.zig`) authors the scene-root `partition` block — the default grid and up to four named grid levels — through document history and reports what the grids do to the actors: occupied cells, the resident ring, the fullest cells, and actors whose live bounds spill past their home cell (pop-in risk rows select the actor). The membership model is `editor/partition_membership.zig`, rebuilt by `editor_app/authoring/partition.zig` on document revision and on a slow tick so late mesh bounds refresh the spill list; the same state feeds the viewport cell overlay (`viewport_overlay/partition.zig`, show-flag `partition`) and the inspector's *World Partition* section. In Play the panel gains a **Streaming in Play** block and the overlay switches to the Play world's driver (`PartitionOverlay.live`): the ring follows the streaming focus, resident cells are outlined, cells in flight are amber and failed cells red. The host (`refreshLive`) reads the grid a few times a second and bumps `LiveStream.generation` only when the readout changes. Rule and format: [Scenes and gameplay — World partition](/docs/systems/scenes-and-gameplay.md#world-partition-authoring).

### Asset Detail and preview viewports

**Asset Detail** (double-click a non-scene/prefab asset) opens multi-instance Class 2 tool windows (`editor/asset_detail.zig`, up to `max_preview_surfaces` = 6) attached as `scene_composite`. The shell (`asset_detail/window.zig`) owns hosts only; each asset type is a module under `asset_detail/kinds/` implementing the `kind.zig` vtable (bind/update/unbind). The shell's compact **Environment** / **Rendering** controls are the default for a Class 2 preview; a kind that declares `VTable.buildSettings` fills that block itself. Every kind gets the window-level dropdown overlay through `Hosts.overlay`. Same asset path raises/rebinds; different assets open concurrently up to the cap.

Every kind mounts one `asset_detail/preview_frame.zig: Frame`: optional toolbar (`tools`, elastic `status`, `actions`), a **stage**, stage-floating badges (`surfaces.hud`), and an interaction hint. `StageFill.plate` is an inset canvas for 2D pixels and empty states; `.scene` is a full-bleed hole for the Class 2 render. A 3D kind returns `Frame.scenePlate()` from `VTable.viewportPlate`, and the shell drives orbit/pan/dolly on it and publishes its rect as the Class 2 pane (`host.setPreviewPane`), so the subject centres in what the user sees as the viewport.

Per kind: **2D textures** get Fit / 1:1 / zoom, an RGB·R·G·B·A channel control (alpha is a shader sample mode, `ImageSample.alpha_luminance`), and a Dark/Light backdrop. **Audio** gets a waveform plate and the transport with a playhead from the mixer's own cursor (no scrub; the mixer has no seek). **Environment cubes** get a full-bleed sky viewer (`kinds/cubemap.zig`) with face-snap verbs, auto-rotate, and a kind-owned inspector (exposure, tonemap, FOV); wheel zooms the lens. **Cubemaps, models, and particles** bind a per-window `PreviewHost` (`editor/preview_viewport/`: GPU sky + mesh path, orbit/pan/dolly, ground grid, surface-local `RenderPipelineConfig`, never the product session pipeline). The preview ground grid and world axes retain reciprocal view depth and test against that surface’s opaque G-buffer depth, accounting for temporal jitter; opaque assets hide the lines behind them. Joint and particle debug ink stays above the scene. Models bind one primitive per cooked part; skinned models raise the bottom band for the clip list and scrub bar. Particle previews retain their sprite atlases and request full-resolution residency; texture readiness is polled independently of playback.

### Input ownership

- **Entering Play:** simulation starts with the editor cursor free. The game's pointer request remains pending until a primary-window viewport press; clicking chrome never grants capture. Escape releases game capture. Game-owned pointer scenes request a free cursor and remain directly clickable. Detached Play uses temporary capture for editor navigation. Starting another Play session resets this ownership.
- **Scene vs chrome:** retained chrome hit-tests first each frame. `RetainedUi.sceneInputBlocked` is true for any chrome hit not under a `scene_passthrough` node (only the viewport spacer sets it) plus active chrome presses/drags, so menus, docks, and popups block fly-camera wheel/look, gizmos, and underlay pointer frames.
- **Captured pointer:** under `PointerMode.captured` the OS cursor is hidden and `MouseFrame.x/y` is a virtual point integrated from relative motion. `InputSystem.syncFrame` publishes `InputState.pointer_captured` and retained chrome goes inert on it. **Never clamp the virtual point**: it is the relative-motion accumulator, so pinning it at any edge zeroes the delta there and mouse look stops dead. Bound what hit-tests with the pointer, never the pointer itself.
- **Keyboard focus:** a press takes focus only on a focusable node; pressing any other interactive node, empty space, or Escape releases it. `RetainedUi.accepts_keyboard` is the host routing flag: Play calls `setAcceptsKeyboard(false)` (blur, block Tab cycle and focus acquisition), Edit restores it. One exception survives Play: a **text field the author clicks** takes focus and keys anyway (a search or filter box is an explicit request to type), and for every frame such a field holds focus — the click that focused it and the Escape that leaves it included — the retained UI reports `Provider.claimsKeyboard()`, which the session uses to hand the game a keyboard-zeroed input frame. Buttons and Tab still cannot take focus during Play. Host shortcuts skip only when `focusCapturesText()`.

Gameplay HUDs can call `hi.ui().worldPointer()` after drawing controls to obtain a viewport-local point and world ray. Captured pointers, controls, modal/popups, and out-of-viewport points yield no ray. Games also exclude their panel backgrounds before handling a world click. The example AI Perception & Brains scene demonstrates this in both Play and standalone.

### Mode chrome (Play, prefab)

Two modes change what controls *mean* without changing how they look, so both state it in the chrome. **Play** tints the toolbar mode pill with the brand glow (`setStatusPillMode`) and swaps the transport to a red Stop. **Prefab** (`editor_app/prefab_chrome.zig`) puts an amber frame inside the viewport, a band naming the template and the scene it returns to, and re-labels Save to **Save Prefab**; rules in [prefabs](/docs/systems/prefabs.md#prefab-mode-chrome). A band that appears is layout: showing it moves `scene_spacer` and `syncViewportInsets` recreates the scene-resolution render targets, which is fine once per open/close and not per frame.

## Contributions (host chrome)

Two paths extend editor chrome. Do not mix them:

| Path | Who | Seam |
|------|-----|------|
| **Host builtins** | Editor product code | `src/hikari/src/editor/contrib.zig` + `bindFill` (retained widget trees) |
| **Game / plugins** | Project + composed packages | C/POD `hi.editor.Api` only — never `contrib.zig` or retained fills directly |

`contrib.zig` is the host-side fixed-capacity `Registry` for dock panels and menu bar entries. Builtins register here and bind retained content with `bindFill`. Game modules and source-composed plugins register through the bridge (`editor/game_contrib.zig`), which maps `hi.editor` declarations into the same registry and host-owned shells. Plugins do **not** call `contrib.zig` or `bindFill`; see [Game editor SDK](/docs/systems/ui-and-editor.md#game-editor-sdk-hieditor) and [Plugins](/docs/guides/plugins.md).

| Host API | Effect |
|----------|--------|
| `registerPanel` | Tab + page in slot `.left` / `.right` / `.bottom` |
| `bindFill(id, ctx, fillFn)` | Build tools/body/footer content when chrome creates the page (**host-only**) |
| `registerMenuGroup` / `registerMenuEntry` | Menu bar groups and leaves (`EditorAction`) |
| `assembleMenus` | Fixed `MenuAssembly` → `menuBar` |

**Lifecycle (create-time only; freeze after chrome build):**

1. `registerBuiltins` (host panels/menus)
2. Game/plugin `registerEditor` via `applyFromGame` (POD declarations → registry)
3. `bindFill` for every **host** panel that owns UI — `panel_fills.zig`: hierarchy tools, console, inspector, profiler, residency, physics, audio, assets
4. `assembleMenus` + `chrome.build` (calls fills; no id special-cases)
5. Host may still own long-lived view structs filled during step 3 (e.g. hierarchy tree after body parent exists)

**Panel ids (builtins):** `hierarchy`, `inspector`, `profiler`, `residency`, `physics`, `audio`, `console`, `assets`. Dock runtime: `EditorApp.workspace.left` / `.right` / `.bottom` (`chrome.DockHost`).

**Add a host panel:** `registerPanel` → `bindFill` (optional) → optional menu entry, plus a row in `editor_app/panel_host.zig` so detach/dock-back can remount it. Do not add parallel tab strips or menu trees.

**Menus (host):** leaves use `EditorAction` / `handleActionFrom`. Custom open-string commands from the game go through `game_contrib.invokeCommand` (shell), not a second action enum.

## Game editor SDK (`hi.editor`)

Optional **create-time** surface so a game module (project root and/or source-composed plugin `editor` modules) can extend editor chrome without linking retained editor UI into standalone game products.

Same C/POD ABI for project and plugins: actions, menus, toolbar items, dock panels, simple host-owned surface controls, and modal alerts. The foundation does not expose retained chrome ownership or raw editor internals; see [Plugins](/docs/guides/plugins.md#editor-contributions) and [Plugins](/docs/guides/plugins.md).

### Exposure (what games see)

| Symbol | Package path | When present |
|--------|--------------|--------------|
| `hi.editor_contrib_enabled` | `sdk/src/hikari_game.zig` | Always a `bool` from `hikari_build_options.editor_contrib` |
| `hi.editor` | same | Full `editor_contrib.zig` when enabled; empty `struct {}` when off |
| `hi.editor.Api` | `sdk/src/editor_contrib.zig` | C-ABI registration table (valid only during `registerEditor`) |
| `GameModule.register_editor` | `sdk/src/game_module_def.zig` | Optional pointer; null if flag off or game has no real `registerEditor` |

`editor_contrib.api_version_current` is **4** (`sdk/src/editor_contrib.zig`). It is independent of `GameModule.abi_version` (`abi_version_current` in `sdk/src/game_module_def.zig`). Changing `Api` layout or semantics → bump `editor_contrib.api_version_current` and the host bridge together; adding/removing the `GameModule` field or handshake semantics → bump `abi_version_current`.

Call `api.check()` first in `registerEditor` (also enforced once in `defineGameModule` before plugins). It requires `api_version` **≥ 4**, installs generation-scoped SceneApi entry pointers for `hi.editor.scene()`, and clears generation on refusal. Table fields that post-date the first contrib revision are still nullable POD slots — reach them through checked accessors (`registerAction`, `registerToolbarItem`, `beginSurface`, `addControl`, `endSurface`, `setControlText`, …) so a missed version check fails cleanly instead of jumping into an undefined pointer.

#### `Api` capabilities

**Registration-only** (inside `registerEditor`; structure freezes afterward):

| Entry / accessor | Purpose |
|------------------|---------|
| `register_panel` | Dock tab in `.left` / `.right` / `.bottom` (`PanelDesc`: id, title, slot, icon, order, `default_selected`, `has_tools` / `has_footer`, `padded_scroll`) |
| `register_menu_group` | Top-level menu group (`MenuGroupDesc`: id, label, order) |
| `register_menu_entry` | Leaf under a group (`MenuEntryDesc`: group, command/action id, label, order, sep, icon) |
| `register_action` / `registerAction` | Unified action (`ActionDesc`: id, label, icon, tooltip, `ActionInvokeFn` invoke with host-filled `ActionContext`, optional `query_enabled` / `query_checked`) — one id space for menus, toolbar, surface buttons |
| `register_toolbar_item` / `registerToolbarItem` | Toolbar leaf (`ToolbarItemDesc`: group, action id, order, sep) |
| `register_panel_update` | Tick callback while that panel tab is selected |
| `begin_surface` / `beginSurface` | Open a host-owned control list for a surface id (typically a panel id; host binds fill when ids match) |
| `add_control` / `addControl` | Append a control (`ControlDesc`: id, `ControlKind`, text, optional action for button/toggle); returns `ControlHandle` |
| `end_surface` / `endSurface` | Close the open surface |

`ControlKind`: `label`, `button`, `separator`, `toggle`, `row_begin` / `row_end`, `column_begin` / `column_end`.

There is **no** `register_command` / `CommandFn` path — product surface is `registerAction` only (`ActionInvokeFn(user, *const ActionContext)`).

**Post-registration** (copy `api.*` or the needed pointers + `ctx` during registration; UI-thread only — action / panel-update callbacks):

| Entry / accessor | Purpose |
|------------------|---------|
| `show_message` | Host modal alert (title + body); wired after chrome/dialogs exist |
| `set_control_text` / `setControlText` | Update host-owned control text by handle |
| `set_control_enabled` / `setControlEnabled` | Enable/disable a control |
| `set_control_checked` / `setControlChecked` | Toggle checked state |
| `hi.editor.scene()` / `SceneApi` | Buffered scene authoring (`EditTransaction` → host `applyBatch` with label); see [Plugin API](/docs/guides/plugin-api.md) |

Icons are a fixed `IconKind` enum mapped by the host into the editor catalog (no free SVG bytes across the ABI). Builtin host menu groups games may attach to: `file`, `edit`, `view`, `help` (ids match host `contrib` builtins). Toolbar group ids: known host groups are placed by the host; unknown groups append.

**Command / action ids:**

- Known engine names (`save`, `undo`, `gizmo_translate`, …) map to host `EditorAction` (see `game_contrib.engineAction`).
- Any other id is **custom**; register with `registerAction` before or with the menu/toolbar/surface entry that references it.

**Panel bodies:** host still owns chrome shells. For game/plugin tabs, prefer `begin_surface` / `add_control` / `end_surface` to declare simple host-owned controls (labels, buttons, toggles, layout markers). That implements the design’s `SurfaceBuilder` model — a POD command recorder, not retained-widget ownership. Use `register_panel_update` and `set_control_*` for refresh; `show_message` for modal feedback. Full retained trees and `bindFill` remain host-only. Detach/dock-back remounts a plugin tab through that same registered fill; the bridge registers an unmount hook that drops its controls' widget ids first, and `set_control_*` plus surface clicks route at the live tree (`EditorApp.panelUi`).

**Not exposed (by design, today):**

- Retained widget construction / `bindFill` from the game module
- Free SVG icons across the ABI
- Raw `SceneDocument*`, `EditorApp*`, `World*`, `Entity`, renderer, or native window handles (scene mutation goes through `SceneApi` / `EditTransaction` only)

### Isolation (must not leak into non-editor builds)

Editor-only code must never compile into standalone `kaji game` / pure game modules. Layers:

| Layer | Mechanism |
|-------|-----------|
| SDK option | `sdk/build.zig`: `-Deditor-contrib` defaults **false**; `editor_contrib.zig` is only imported when true |
| Barrel | `hi.editor` is empty when `editor_contrib_enabled == false` |
| Game module | `defineGameModule` sets `register_editor` only if flag **and** a real registration path exists |
| Editor recompile | `editor/project.zig` always passes `-Deditor-contrib=true` for game-module builds under the editor |
| Host product | Editor host always has contrib types (`build.zig` host options); still only *calls* game registration when non-null |
| Sample / project / plugin code | Keep extensions in editor-gated units (`editor` plugin modules, or a project file imported only when `hi.editor_contrib_enabled`) so the false branch **never** analyzes that code |

Do **not** use `hi.editor` from always-compiled game units (`session.zig`, entities, …). Do **not** store the temporary `*const Api` past `registerEditor` — copy the table (or needed fn pointers + `ctx`) for post-registration calls (`show_message`, `set_control_*`). Those stay valid for the game-module generation and are editor UI-thread only.

### Host wiring

| Piece | Path | Role |
|-------|------|------|
| Registry | `editor/contrib.zig` | Fixed-capacity panels + menus (host builtins; game/plugin via bridge) |
| Bridge | `editor/game_contrib.zig` | Maps `Api` → registry + actions / toolbar / surfaces / panel-update tables; host **string intern pool**; `setAlert` for modals |
| Create order | `editor/editor_app/app.zig` | builtins → `applyFromGame` → `assembleMenus` → **`freezeChrome`** → `chrome.build` (host fills) → later `setAlert` |
| Game recompile | `editor/editor_app/game_recompile.zig` | `rebindFromGame` — clears invoke/update hooks, re-runs registration (new dylib fn pointers); structural menus/panels/toolbars/surfaces stay |
| Menu / toolbar click | `editor/editor_app/shell.zig` | `EditorAction` → `handleActionFrom`; custom id → `game_contrib.invokeCommand` (warns if missing) |
| Panel tick | `editor/editor_app/tick.zig` | `game_contrib.tickSelected` when `update_count != 0` |
| Alerts | `editor/dialogs/alert_dialog.zig` | `showText` free-string modal used by game `show_message`; `showPrompt` relabels the confirm button (`Action`) for the version warning and the scene-changed-on-disk reload |

**Create vs reload:** panel / menu / toolbar / surface **structure** is create-time (frozen after assembly). Ids/labels are **host-interned** so they never dangle after dylib unload. Command/action and panel-update **handlers** rebind on every game-module reload. Adding a *new* structural id after freeze requires reopening the project (register returns failure + warn). No per-frame registration.

### Samples

Primary full demo (actions, menu, toolbar, panel, surface controls, `show_message`, `setControlText`):

- `hikari-games/example/plugins/example_capability/editor/root.zig`

Project glue for plugin editor registration:

- `hikari-games/example/src/root.zig` — `registerEditorPlugins` when `hi.editor_contrib_enabled`

Verify in editor with the example project: **View → Example Capability**, toolbar **plugins** group, and the **Example Capability** right dock panel. Standalone `kaji game` must not compose editor plugin modules. Put editor-only code in an `editor` plugin module (or a project file gated on `hi.editor_contrib_enabled`); call `api.check()` first; copy `api.*` for post-registration calls.

### What not to do

- Depend on `hi.editor` from always-on game code.
- Force `-Deditor-contrib=true` on standalone game product builds.
- Build a parallel command-id enum for chrome actions the host already owns — map known names or use custom open strings.
- Call `contrib.zig` / `bindFill` or keep retained widget pointers from game/plugin code — use `hi.editor` surface controls only.
- Assume free SVG icons, document mutation APIs, or raw `EditorApp` / `World` handles across the ABI.
- Treat this as a general plugin host (no hot-swap of structural contribs without reloading / reopening the project).

## Agent MCP (per-project)

The editor can expose a local **Model Context Protocol** server so AI agents operate the same authoring surface as chrome (document SSOT, history, Play gates). **Off by default.**

| Setting | Location |
|---------|----------|
| Enable | **Editor Settings → MCP → Enabled** |
| State | `<project>/.engine/user/mcp.json` (durable enabled/host/port; survives `--clean`); Bearer token in OS secure storage (`platform/secure_storage.zig`) |
| Client config | Pretty-printed JSON on the MCP page (includes Bearer token); **Copy configuration** writes it to the OS clipboard (`platform/clipboard.zig`) |
| Rotate secret | **Regenerate MCP token** (immediate secure-store write; enable/disable does not rotate) |

Transport: native MCP Streamable HTTP at loopback `/mcp`; every POST requires `Authorization: Bearer <token>`. Implementation: `src/hikari/src/editor/agent/`.

MCP asset authoring covers animation graphs, graph materials (surface and
volume), particle systems, AI Brains and UI documents through one ETag-guarded
JSON Patch plane; the capabilities call lists the supported kinds
(`asset_kinds`). Native
editor validation runs before the atomic source write; dirty open detail windows
conflict instead of being overwritten, and clean ones refresh on the next UI
tick. The adapter registry is the extension seam for future JSON asset editors.
Asset creation/import/move/duplicate/delete use the shared editor lifecycle
service and recipe registry rather than MCP-specific file operations. Model,
texture, and audio import settings reuse the inspector's Shinra schema and
sidecar IO; source move/duplicate/delete keeps that sidecar paired with the
asset. Project configuration, plugin enablement/settings, gameplay tags,
and editor preferences likewise adapt their existing schemas and stores; stale
writes require a fresh SHA-256 ETag and open dirty surfaces conflict.

Scene/document lifecycle is also semantic rather than action-emulated:
`scene_open`, `scene_create_and_open`, `scene_save_as`, `prefab_open`, and
`document_close` route through `editor_app/document_lifecycle.zig`. Dirty
transitions reject by default; explicit save is supported, while discard needs
matching document id/revision preconditions (including a dirty stacked parent
when leaving prefab isolation). The MCP adapter remains contained under
`editor/agent/`.

`viewport_capture` requests the renderer's actual asynchronous final-viewport
readback; `viewport_capture_status` returns the resulting HDR EXR or SDR BMP
path under `.engine/screenshots/`. Chrome and MCP share a coordinator around the single
renderer completion mailbox. Chrome presents the saved filename as an actionable
toast; clicking it reveals the selected file in Finder/Explorer.

For repeatable compositions, `viewport_camera_get`, `viewport_camera_set`, and
`viewport_camera_frame` expose the main edit-mode fly camera without turning it
into MCP-owned state. The shared viewport controller remains authoritative for
pose validation and matrix publication; the agent adapter only translates JSON
and wakes an Efficient-mode scene encode before a subsequent capture.

## Actions, shortcuts, and the menu bar

Editor “commands” for chrome are **not** a separate action-id registry and are **not** `history.Command` (document undo). They are `EditorAction` values in `src/hikari/src/editor/shortcuts.zig`. Menus, keyboard shortcuts, tool buttons, the **command palette** (⌘/Ctrl+P — `editor/command_palette.zig`), and automation dispatch the same enum through `authoring.handleActionFrom` and the scoped `commands.Router`. The palette also ranks project assets and opens a hit through the same browser selection path.

```
ShortcutRegistry.poll  ──┐
MenuBar leaf click     ──┼──► EditorAction ──► scoped command route ──► executor
Tool chrome (save, …)  ──┤
Command palette        ──┤
Automation / MCP       ──┘
                              ▲
editor_state.mode_changed ────┴──► ActionContext ──► menu enablement
selection / history (polled each shell tick)
```

| Piece | Location | Role |
|-------|----------|------|
| `EditorAction` | `editor/shortcuts.zig` | Stable action identity (save, undo, gizmo modes, menu stubs, …) |
| `actionInfo` / `ActionContext` | same | Labels, optional SVG icons, shortcut glyphs, gates (`edit_only`, needs selection / undo / redo) |
| `ShortcutRegistry` | same | Key chords → `EditorAction`; `installDefaults`; `chordFor` for display |
| `commands.Router` | `editor/commands.zig` | Preview/target/bubble route over retained scopes; stale generational scopes cannot receive callbacks |
| `authoring.handleActionFrom` | `editor/editor_app/authoring.zig` | Play/edit gate plus sole source-aware route entry |
| `authoring.executeAction` | same | Root-scope production fallback; executes without recursively routing |
| `authoring.processShortcuts` | same | Polls registry from the **key window** keyboard; authoring chords are primary-only / edit-only / not-while-typing; `close_window` (⌘/Ctrl+W) always applies to the key window |
| Menu bar | `editor/ui/components/menu_bar.zig` | Retained top strip + cascading popups (not OS menu APIs) |
| `history.Command` | `editor/history.zig` | Invertible **document** mutations only — different concept |

**Close Window (⌘/Ctrl+W).** `EditorAction.close_window` posts the same cancelable `WindowCommand.close` as the caption close control: a **tool** key window retires (GPU detach → native destroy) without quitting; the **primary** key window continues the existing process-quit path (macOS `windowWillClose` / Windows primary `PostQuitMessage`). Bare **W** remains gizmo Translate. Works in Play and while a text field has focus.

**Menu bar.** Built in `editor/editor_app/chrome.zig` above the toolbar. Popups parent to the retained root so they float over docks without reflow. Static trees use `MenuSpec` (label, optional SVG `icon`, optional `EditorAction` leaf, nested `children`, separator). Icons are the same outline SVG model as the rest of chrome (`ui.icons.*` or a custom `ui.Icon`); leaves fall back to `actionInfo(action).icon` when `MenuSpec.icon` is null. Rows reserve a fixed leading icon column so labels align when only some items have icons. Host menus (`contrib.registerBuiltins`): **File** (New Scene / Open Scene / Close Prefab / Save / Save As / Project Settings / Editor Settings / Close Project / Close Window / Quit), **Edit** (Undo/Redo/Copy/Paste/Paste as Child/Duplicate/Delete/Rename/Focus), **View** (Command Palette / gizmo modes + space/snap / Debug Tool Window), **Help** (About). New Scene / Save As use `name_prompt.zig`; Open Scene uses the asset picker (`.scene`). Interaction: click top title to open, hover another title while open to switch, hover/click submenu rows for cascades, Esc or outside click to close. `shell.process` calls `updateMenuBar` then routes the activated action through `handleActionFrom`.

**Diagnostic input ownership.** Viewport visualizers and temporal diagnostic controls are toolbar-button-only in the editor, with no default shortcuts, menu entries, or command-palette entries. Engine/native hosts never reserve diagnostic keys (including VSync toggles), and shared overlays display no hardcoded input hints. Games may explicitly author their own diagnostic actions and bindings; the engine supplies no fallback keys.

**Debug browser.** The viewport's **Debug** button opens one browser for Buffers, Effects, Volumetrics, Shadows, HDR, Temporal, GI and Meshlets. Select a category directly, then use **Overview** or a named channel in the view dropdown. Every overview tile has an expand button; **Back to overview** returns to the grid. Repeated presses do not cycle through hidden pages. Overview layouts follow channel count (3×3, 3×2, two panels, or one full-viewport channel for HDR).

The browser controls overlay the existing viewport, preserving its dimensions and temporal history. **Options** contains the category legend and any category-specific tools; Temporal exposes **Freeze jitter** and **Reset history**. Switching category or closing the browser clears the jitter override. Close or press Debug again to restore normal rendering. Camera interaction remains available outside controls; no new diagnostic hotkeys are installed.

Renderer visualizers hide the game HUD and editor scene gizmos while active. Diagnostic captions and unavailable-source explanations are drawn by the renderer, independently of game UI. Editor chrome remains visible; gameplay and game-authored input bindings continue running.

GameSDK uses the same category/channel catalog through `hi.debug_views`. For example:

```zig
hi.debug().setVisualizer(.{ .enabled = true, .category = .shadows }); // overview
hi.debug().setVisualizer(.{ .enabled = true, .category = .shadows, .view = 1 }); // virtual depth
hi.debug().setVisualizer(.{}); // normal rendering
```

`view = 0` selects the overview; subsequent indices follow `hi.debug_views.describe(category).views`. Invalid channel indices fall back to the overview. There is no game-side label draw call: captions are automatic. Diagnostic selection, including the expanded channel, is restored with other runtime settings when Play stops.

**Enablement.** `ActionContext` is built from play mode, selection primary, and document history (`undoLabel` / `redoLabel`). `syncMenuBarEnabled` runs from `authoring.syncActionChrome` each shell tick and on `editor_state` `mode_changed` (via `game_recompile.syncStateControls`, same path as recompile-button enablement). Do not invent a second enablement bus for menus.

**What not to do**

- Do not add a parallel command-id enum for menus or the command palette — extend `EditorAction` and `actionInfo` (palette catalog is `command_palette/commands.zig`).
- Do not call OS menu bars (NSMenu / Win32) for editor chrome; keep platform frontends thin and chrome platform-neutral.
- Do not route UI actions through `history.Command`; that type is only for undoable document edits.
- Do not fork Play/Edit policy — mode lives on `SessionCore`, mirrored by `editor_state` through `enterPlay` / `enterEdit`.

## Editor documents and property editing

`SceneDocument` owns authored scene data, stable string actor IDs, deterministic serialization, lookup, history, and atomic replacement. The runtime world is a live preview, not the editor's source document.

### Document entry (shared mutation plane)

**Chrome, plugins, and MCP must enter authoring only through** `editor/authoring/scene_mutation.zig` (`apply` / `applyBatch` / `applyJoin` / txn helpers). That plane is the product SSOT: one op vocabulary, `max_operations`, validation-on-clone, and a single undo label per batch.

| Front door | Path into the plane |
|------------|---------------------|
| Host chrome (hierarchy, inspector, gizmos, shortcuts) | `editor_app/authoring/*`, `property_binding` → `scene_mutation` |
| Plugin SceneApi | `hi.editor.scene()` / `EditTransaction` → host bridge → `applyBatch` |
| MCP | `agent/scene_edit.zig` (wire) + `agent/plane.zig` → `scene_mutation` |

Under the plane, `editor.Document` history still records **`ActorMutation`** values (`scene/scene_actor_mutation.zig`) as the live/history payload. `SceneEditAccess` applies those mutations to the live world so preview and document stay in sync; invertibility drives undo/redo. Component fields go through `ActorMutation.component_field` / `ComponentFieldEdit` — never poke live entities directly.

The authored document is the save authority. Opening a scene and loading its assets do not create edits. Idle ticks and saves never copy live component rows back into the document: those rows may contain expanded defaults, preview state, or actors from an outgoing scene while a replacement loads. Deliberate live-row capture uses the explicit, non-undoable `sync_open_components` operation.

Property controls bind stable actor IDs and property paths through `editor/property_binding.zig`. Scalar, boolean, and choice bindings preserve begin/write/commit undo boundaries (same plane). Transactions are fail-atomic: a failed write or history commit reverts every eagerly applied command, and editor workflows must cancel and report the error rather than continue with a partial edit. Retained widgets must not keep pointers into document actor storage.

The hierarchy (`editor/panels/hierarchy.zig` → `editor/panels/hierarchy/`) groups actors under
the scene name/id. Edit and Play mirror the live world: a **Global** group for
loose/runtime entities (subsystem and `spawnLoose` spawns), then one group per
scene layer (including additive loads), with parent attach/detach reflected
inside each group. While an authored whole-world replacement is queued or loading,
Edit shows the new `SceneDocument` tree; the outgoing scene and withheld incoming
actors remain viewport loading state, so they are not mislabelled as runtime actors
against the new document. The live tree resumes when replacement finishes.
Before launch, the document tree is also the fallback. Actors with broken soft asset refs (failed mesh/material/script,
unknown archetype) use **destructive** name/icon styling; the inspector shows an
**Asset issues** banner with path + status. Full cause→result tables:
[Assets and Shinra — soft refs](/docs/systems/assets-and-shinra.md#soft-refs-and-loadhot-reload-recovery).
The inspector also reflects authored transform metadata and user-data schemas.
Identity shows **Active** (entity `active`); Physics shows **Enabled**
(`physics.is_active`); Render shows **Visible** (`render.is_visible`). Live
edits flow through `ActorMutation` (including `.active`) so document and world
stay in sync — [Active / enable trio](/docs/systems/scenes-and-gameplay.md#active--enable-trio).
Editor overlays, picking, grid/origin axes, light/trigger gizmos, and game
viewport UI all compose into the shared final UI stream.

## Profiler

Two layers:

1. **Build** — Kaji `--profiler-timing` / `--profiler-timing=true|false` compiles the profiler subsystem in or out (omitted: on in debug, off in release). Without it, the Profiler tab shows “Instrumentation off”.
2. **Runtime** — **Profiler on/off** (the toggle button in the tab's header strip) is the global sampling switch (default **off**). With it off, the panel shows only the header strip and an empty stage. Turn it on to reveal the summary card, the collapsed **Options** section and the zone list, and to enable **Record** (NDJSON under `<project>/.engine/traces/<stamp>-<platform>-<conf>-editor|game.json` via `io_stream`; the button becomes **Stop** and a recording strip under the header shows elapsed time, frames written, hitch samples and the file path while the file is open). Under Options, **Write every** `1`/`2`/`3`/`6` frames (in-game slider 1–12; default **6**) throttles how often a snapshot is written — use **1** when hunting single-frame hitches. The chosen rate is stored in the file header as `sample_every`. Works in Edit and Play. **Order** `Name`/`Cost` picks stable A–Z (default) or by time.

Each recording starts with a schema-v4 QA handoff envelope: engine name/version and build VCS revision, project name/id and the project VCS revision observed when recording starts (probed asynchronously from a mapped Perforce client or Git, so it stays correct across syncs and hot recompiles), capture UTC, product, configurations, platform/graphics API/GPU preference, linkage, Zig version, CPU count, sample rate, and compiled instrumentation switches. It excludes absolute paths, user names, and host identifiers. Every frame sample carries `ms`, its offset on the monotonic clock from the moment the recording opened (`t` counts written samples, not frames). Every zone row carries `s`, its start offset against an origin shared by all threads in that frame, with `ts` saying whether those offsets are real; GPU passes and detail sections keep `s` at zero. Schema 5 adds GPU detail: with `gts` set, GPU rows carry a real `s` on the GPU clock (first pass = 0) and `gpu_span_ms` covers first start to last end; `stages` gives per pass the Metal vertex/fragment split (`vms`, `fms`, also shown as `<pass>.vertex` / `<pass>.fragment` rows) or the D3D12 pipeline statistics (`vs`, `tri`, `ps`); `prof_ms` is what the previous written sample cost the game thread; `view` repeats the render geometry whenever it moves; `draw` gives the main viewport's camera triangles by class (`opaque`, `masked`, `fade`, `transparent`, `overflow`); `counters` gives its GPU frame counters by name (`direct_refine_pixels`, `direct_refine_edges`, `volumetric_sun_froxels`, `volumetric_sun_rays`, `reflection_rays`, `reflection_screen_reuses`, `reflection_hit_shades`, `reflection_bounce_rays`). The header adds `display_refresh_hz`, `frame_rate_cap_hz` and `vsync` (frames repeat them as `pace` when the editor's pacing or the game's display settings change): a frame held at the cap and a GPU-bound frame both show a long `render.drawable_acquire`, and only these tell them apart. See [rendering](/docs/systems/rendering.md) for reading them. A **hitch escape hatch** writes a frame costing more than `hitch_ms` (default 33.3 ms) even when the throttle would skip it, marked `"hitch":1`, limited to one out-of-band write per period. Readers keep hitch samples out of the latency histogram while the timeline and worst-frame search see them.

**Profiler Trace Viewer** (View → Profiler Tracer Viewer) is the offline Class 1 tool window for those recordings; **Open trace** and **Compare trace** start in `<project>/.engine/traces`. Four panes read one session:

- *Summary band:* mean/P95/P99/max, share of samples over the 60 and 30 FPS budgets, and a log-spaced latency distribution, plus the QA envelope for A (and B). While a large file indexes, the band becomes the progress readout.
- *Timeline:* horizontal axis is **wall time**, not sample ordinal. CPU and GPU draw as min/max envelopes plus a mean line (an envelope keeps a downsampled chart honest); over-budget frames are ticked along the top; a ribbon shows what the frame governor gave up. Primary-drag selects an interval, double-click or `Z` frames it, `W` selects the worst frame, `F` fits, `Esc` clears.
- *Cost table:* ranks zones and passes over the selected scope; clicking a row plots that zone across the capture as a timeline track. With a comparison open it becomes an A→B regression ranking.
- *Flame graph:* the selected sample as nested spans, one lane per thread plus GPU and detail sections on one horizontal scale. On a v4 capture CPU lanes sit at their measured offsets, so the space between spans is real idle and a lane that waited reports its busy share. The recorder writes each thread as a depth-0 header carrying the thread's root time, so a depth-0 header is a lane boundary.

Large traces stay memory mapped and index over bounded editor ticks with a budget in milliseconds, not bytes. The index keeps fixed-ceiling summaries, an aggregation pyramid, log-spaced histograms, whole-capture name aggregates, and a bounded per-name bucket series that folds in pairs at its resolution cap, so a ten-minute capture costs what a ten-second one does. Rows decode through a direct reader for the exact recorder shape (falling back to the general JSON scanner on mismatch), names resolve through a row-order memo, aggregate names alias the map, and a frame folds all-or-nothing. The native bridge is `platform/dialogs.pickFile`.

**Thread registry.** Worker and game threads register in a process-wide table in the platform library (shared across app / physics / render dylibs). Registration is re-affirmed each `frameBegin` / `threadBegin` so a lost slot cannot stay invisible for the rest of the session. Each completed game/physics/render ring carries its producing `world_id`; capture admits only the active world plus id `0` session workers such as audio. GPU timestamp snapshots carry the same id, detail EMA histories reset across Play/Edit, and the panel clears until a newly scoped frame exists. File-trace frames include `world_id`. Capture also skips dead TLS left by threads that exited without unregister and always includes the capturing game thread when its completed ring matches. `frame_ms` is the game-thread root time when present, otherwise render.

The live Profiler summary labels that duration **CPU**. Its **FPS** readout
uses the session render thread's viewport delivery meter, the same source as
the viewport toolbar and gameplay API. CPU work duration excludes the time
between ticks and cannot be inverted to measure rendered FPS. GPU milliseconds
remain a separate readout; the CPU history strip and trace `frame_ms` retain
their existing CPU-work meaning.

When built in, the right dock **Profiler** tab (`editor/panels/profiler_panel.zig`) shows per-thread zone samples for the live frame (game, physics, render, **audio** device callback, **audio.worker**, nested scopes — including `publish` / `world.physics_sync` / `audio.tick` / `audio.drain` / `audio.play.apply` / `audio.decode` / `audio.mix`). The summary card carries the smoothed frame time, fps and GPU time, a fixed-scale (0–40 ms) history strip of per-frame cost folded by maximum so a single-frame hitch is never thinned away, the window peak, and a budget bar against 16.7 ms. The zone list is one virtualised paint leaf fed by a display model (`panels/profiler/live_table.zig`) rather than the raw snapshot: values are EMA-smoothed with a held peak tick on each bar, a zone that vanishes is held dimmed in place for about a second before it is dropped, repeats of one zone fold into a single row with a `×N` count, order is stable across refreshes, and thread headers collapse on click. A **Filter zones** well above the list narrows it by name: space-separated terms, case-insensitive, a zone stays when any term matches and a thread header stays while any zone under it does; the filter is re-applied on every refresh, so the narrowed view keeps updating. Header, summary, options and the filter stay pinned; only the list scrolls under sticky column captions. The tree is built once — nothing is added or removed per refresh. Further switches under **Options → Detail**:

- **Editor timing** (editor product only) — retained UI composition (`ui.input` / `measure` / `arrange` / `paint` / `compose`) plus chrome phases (`hierarchy`, `inspector`, `assets`, `shell`, …) under an `editor` section.
- **Entity timing** — per-entity play-frame costs for Zig `update`, script update, `onCollision`, and `onMessage`, listed as `entities → name → method`. Rows stay sticky with an EMA-smoothed ms value and drop after ~3s quiet.
- **Message timing** — flush cost by interned message name under `messages`.

Detail switches are off by default. With the Profiler tab open in Edit, frame pacing stays active so editor samples are not idle-throttled.

**Content budgets** (a collapsible section, live whether or not sampling is on) shows one row per content budget — decals on screen, fog volumes, shadow views, lights per cluster (longest run any cluster holds vs `quality.local_lights`), awake particle systems and volumetric material bricks — as `used / budget` with a bar and the count dropped. A row turns amber at 85 % and red when anything was dropped, and stays red for about a second so a single-frame drop between two refreshes is still seen. Values come from where each system decides what renders (World publish for fog volumes and particles, the render thread for the rest, worst surface per frame) and cost a few integer writes while the panel is closed. Recorded traces (schema 6) carry the same counts per sample and the budgets in force; `tools/profiler/trace_report.py` reports peak use against each budget and how many samples dropped content. Games read the live values from `runtimeInfo().graphics.budgets`.

## Asset References

**Asset References** (View → Asset References, also in the command palette) is a Class 1 tool window that draws what an asset needs and what needs it. The left pane lists every file asset in the project behind a search box; clicking one puts it in focus. With **Follow selection** on (the default) the focus tracks the tile highlighted in the Asset Browser, so the window answers "what is this wired to?" for whatever is being looked at.

The picture is a layered graph: assets that *use* the focused one stack to its left, assets it *uses* stack to its right, so every edge points rightwards and the arrowhead confirms the direction rather than being the only clue to it. **Uses / Used by / Both** picks a side; **Direct / Whole chain** picks one hop or the transitive closure (six hops, cut at 400 nodes with a banner saying so). Hovering a node lights its edges and dims the rest and shows the path, the kind, its own counts, and the slot the focus reaches it through (`material ×2`, `mesh`, `skybox.primary`). Clicking a node re-roots the graph on it; **Back** (or Backspace) walks the history; double-clicking reveals the asset in the Asset Browser. **F** or **Fit** frames the picture; the wheel zooms around the pointer and a trackpad swipe pans.

The index is honest by construction. Every authored document that can carry references (`asset_refs.canReference`) is read once, a few per editor tick so a large project never stalls a frame, and every reference slot in it (`asset_refs.collectReferences`, the same table the rename and delete flows use) becomes an edge. Nothing is inferred from names. A reference to a path no asset has is kept as an **unresolved** node, drawn in the destructive hue with a "missing" tag, because a scene pointing at a material that no longer exists is exactly what the window is for. Two assets of different kinds can share a stem (a model and its generated material); typed slots keep them apart. Documents the editor cannot read are counted in the status line rather than dropped silently. **Rescan** rebuilds the index; a catalog whose size changed rebuilds on its own.

## Residency tab

The Residency tab always names the active world role and session-local world ID. World-owned rows are never summed across the retained editor and disposable Play worlds: scene jobs, physics publications, and world-specific GPU rows carry `world_id`, and a sample from the previous world is discarded at a Stop boundary. GPU object IDs use the same world namespace, so two live scene graphs cannot alias in renderer residency.

CPU vertex/index mirrors and geometry staging free lists are also measured from the active World's component, swarm, and render-command ownership. Their high-water marks live on that World; the process-wide cross-module ledger is only a balance checker and is never used as the panel total.

The overview cards are built once and only their labels change; a drill-in page is rebuilt only when what it would draw has changed (a navigation or filter change, or a reading that differs from the one on screen), never on a refresh that read the same thing. Texture mip streaming is shown as a budget block — resident bytes against the configured budget, with a bar that turns warm at three quarters and hot at the budget — on the GPU page and on the overview's GPU card.

Physical caches that are genuinely shared are shown as **SESSION**, not copied into either world's total: decoded `AssetStore` entries, hashed geometry, textures, pipelines, and process-level pools. The GPU page shows WORLD and SESSION badges; the overview's world GPU total counts only WORLD rows. This distinction is intentional—the physical cache exists once even when both worlds reference it, so attributing it to both would double-count memory.

## Physics tab

The right-dock **Physics** tab is always part of editor chrome. Build with `--profiler-physics` to enable its backend-neutral 5 Hz snapshot; otherwise it shows a compile-option notice. A status strip names the loop state and backend; a summary card carries the smoothed step cost, the achieved cadence, the fixed step, and a cost strip (one column per published sample, about fifty seconds) with a budget bar against one pace period. Sections below it cover stepping (steps per second, catch-up steps, dropped simulated time, pace target, step size — folded by the physics thread itself, pace waits excluded), body motion/sleep state, collider shapes and joints, contacts and collision-buffer pressure, collision resource pools, and memory. The tree is built once; a sample only writes values into bound rows, so nothing flickers or scrolls back at the 5 Hz refresh. Each editor world owns a separate middleware backend and the snapshot carries its engine `world_id`; Stop cannot present the last Play sample under the editor heading. The allocator line is explicitly session-shared because both backend allocations pass through that one instrumentation allocator. The tab reads only a synchronized physics-thread publication and does not call middleware or lock the live world. See [Physics — Physics diagnostics](/docs/systems/physics.md#physics-diagnostics).

## Audio tab

The **Live output** card shows two-second Music/SFX/Voice/Master waveforms with peak/RMS meters, peak hold and clipping; click a row to expand. It needs no profiler flag. Capture and telemetry updates stop while the tab is hidden or closed, and the tap is absent from standalone builds. Bus taps are dry and pre-master; Master includes reverb. See [Live output monitor](/docs/systems/audio.md#live-output-monitor) for signal points and callback performance contracts.

The right-dock **Audio** tab is always part of editor chrome. A stable **Mix** strip (master + bus gain/mute, reset) posts into `AudioSystem` and works whenever the session audio system is live — no `--profiler-audio` required. Build with `--profiler-audio` to enable the diagnostics section below the strip (worker-published snapshot); otherwise that section shows a compile-option notice. Diagnostics summarize bus occupancy vs soft caps, voice lane utilization, duck multipliers, command-queue depth, decode-cache size, and streamer ring pressure. The tab never locks the mixer or inspects live streamers; Mix is post-only and the snapshot is a synchronized audio-worker publication. See [Audio — Editor](/docs/systems/audio.md#editor) and [Audio — Diagnostics](/docs/systems/audio.md#diagnostics).

Standalone / Play also expose the same snapshot through `World.profiler_overlay` (`profiler/profiler_overlay.zig`). The sample game binds `toggle_profiler` to F4 and draws the overlay from its `debug` entity; the engine does not hard-wire that key. Build flags and modules: [Build and packaging](/docs/guides/build-and-packaging.md). Frame placement: [Application lifecycle](/docs/systems/lifecycle.md#entity-lifecycle).

## Play versus Edit

In Edit, the scene is rendered but simulation is frozen. In Play, game UI is clipped and remapped to the scene viewport; editor chrome remains outside it. Do not let editor chrome paint behind panels or let editor overlays appear in standalone game rendering.

Cross-scene game UI belongs on `GameSubsystem` (session services), not on scene actors. In-game Quit in the editor stops Play and switches back to the retained document world; it does not close the editor. See [Session services](/docs/systems/session-services.md).

**Document vs live world on Stop.** `SceneDocument` is the only authoring source. Entering Play builds a disposable world from `document.saveToUtf8`; Play never writes into the document or the retained editor world. Runtime `Scene.load` mutates only Play. Stop (`enterEdit`) rebinds the retained editor world and resets the session path to `document.path`, so unsaved edits survive even when gameplay changes scenes.

**Neither transition blocks the frame.** The Play copy is built across ticks on the scene-load budget while the authoring viewport keeps rendering and taking input; the Play button reads **Starting…** for that window, `canAuthor` is false (the document was captured at the click), and clicking again withdraws the request. Stop is a single-call switch with the copy retired invisibly afterwards. Editor frame pacing stays at the interaction rate for both, so a transition is never idle-throttled.

**Play fence on Stop (once):** `GameSubsystem.onPlayEnded` (game HUD flags) then `World.ui.resetInteraction` (engine modals/popups/focus/drag/scroll). Not on Play enter, and not in `onSceneWillUnload` (that also fires on mid-play `Scene.load`).

**Opening another scene while dirty.** Double-clicking a scene asset does not auto-save. If the document is dirty, the editor shows a Save / Discard / Cancel confirm modal (`editor/dialogs/confirm_dialog.zig`) and only proceeds after Save or Discard.

**The open scene changing on disk.** `SceneDocument.disk_stamp` (size + mtime from `fs.Stat`) records what the file looked like at the last load or save; `editor_app/document_watch.zig` stat-polls the path twice a second in Edit mode (never during Play, never while another modal is up) and raises a Reload / Cancel prompt on the alert dialog (`alert_dialog.showPrompt` with the `.reload` action) when the stamp differs. Reload re-reads the file through `asset_flow.reloadFromDisk` (same replacement path as opening a scene, minus the same-path no-op) and discards unsaved edits; Cancel keeps the in-memory document and adopts the new stamp so the same edit is not asked about again. The cook daemon is not the source here: it reports cooked outputs and cannot tell an outside write from the editor's own save. Prefab documents and the stacked parent scene are not watched.

## Editor state and events

`src/hikari/src/editor/state.zig` is the editor-local state coordinator and typed event queue. It is MQ-like in that producers enqueue state transitions and subscribers receive ordered events, but it is deliberately an in-process editor service rather than a cross-thread or runtime-wide message broker.

The coordinator owns editor mode and deferred-work readiness. Feature payloads stay with the owning subsystem: the game recompiler retains its staged module and Shinra retains its asset notifications. The coordinator tracks only the work kind and count, then publishes `work_ready` when the current editor mode permits application. Asset hot reload (`Work.asset_reload`) is deferred while Play is active; apply at a frame boundary after Stop (or when Edit allows).

Events are dispatched synchronously on the editor thread from `EditorApp.tick`. Hooks may enqueue more events while dispatch is in progress; those events are delivered later in the same ordered pass. Hooks must remain fast and must not retain borrowed event data. Deferred work keeps its payload in the owning feature; apply only after `work_ready`, then `complete`.

Do not fork mode policy. Simulation mode lives on `SessionCore`; the editor coordinator mirrors it only through `EditorApp.enterPlay` / `enterEdit`. Deferred features gate on the coordinator after those paths run.

`mode_changed` also refreshes chrome that depends on Play vs Edit, including menu-bar enablement for `edit_only` `EditorAction`s — see [Actions, shortcuts, and the menu bar](/docs/systems/ui-and-editor.md#actions-shortcuts-and-the-menu-bar).

## Performance contract

Keep UI state and layout backend-neutral. Avoid per-widget allocation and temporary formatted IDs in frame paths. The atlas/glyph geometry is precomputed; capacities are retained/prewarmable; layout reflow sorting is conditional; idle navigation short-circuits. Maintain one flattened UI draw on both Metal and D3D12.

Production text shaping, fallback, raster modes, glyph residency, and editing
semantics are described in [Text and fonts](/docs/guides/ui-widgets.md#text). Editor and game
UI share that engine; font/page/mode changes must remain vertex data rather than
new draw calls.

Motion (fade, spring, enter/exit) is a shared value layer — not a second widget system. Retained chrome uses `opacity` / `shift` on `WidgetStyle` plus `editor/ui/overlay_motion.zig` for modal/splash enter-exit. See [Motion Kit](/docs/systems/motion-kit.md).

For practical authoring details, see [User interface](/docs/guides/user-interface.md).

### Live actor inspection during Play

During Play the inspector reads the selected actor in the Play World, including
its local transform, activity, parent, tags, built-in fields and editor-visible
user component fields. It does not fall back to authored values. Runtime-added
components appear immediately with a **Runtime** pill; removed components leave
the view. A declared component removed and re-added during Play also gets the
pill. `hidden` fields and `Data.state` remain excluded. Transient fields appear
only when also marked `read_only`; these live diagnostics are never serialized
into the scene or shown as editable authoring fields.

The hierarchy runtime marker and `is:runtime` / `is:authored` filters follow actor
creation provenance, independently of the document open before Play. Actors
loaded from scenes (including authored prefab instances and additive layers)
remain scene-authored after gameplay navigates to another scene. Code-spawned
actors and prefab instances remain runtime even when attached to a scene layer
or reusing an ID from the original document. Inspector component badges use the
same provenance plus runtime component attachments. This classification does not
change which document is saved or restored on Stop.

Selection of a runtime-spawned actor uses the same view. Swarm instances have no
individual actor inspector.

`scene/observation.zig` owns one selected-actor subscription per World. Mutation
APIs, component membership changes, simulation producers and the unified event
dispatcher publish coalesced value/structure flags. Native tick batches retain
their batching; only the observed actor is invalidated. Borrowed Zig pointers
allow ordinary assignments, so callbacks conservatively invalidate the selected
actor even when a callback did not change a field. This is not write interception.
There is no timer, World scan, JSON snapshot, or per-frame value comparison.
Idle synchronization consumes a flag without reading fields; after invalidation
only the selected actor's displayed fields are read. Labels retain their widgets
and unchanged text is not reallocated. Membership changes rebuild sections while
preserving their collapse state. Hiding/unmounting the panel stops observation.
Reads and subscription changes happen at the editor's post-tick synchronization
boundary, after gameplay workers have joined.

`editor/panels/inspector/live.zig` owns the retained view; `live_values.zig` dispatches
typed built-in fields and generated user-field accessors. Generated user accessors live in
the compiled game module, from `sdk/src/define_component/value.zig` and
`properties.zig`, rather than an on-disk per-component generated file. They
preserve optional payloads, nested paths, enum names, strings, assets, and exact
64-bit integers. Rebuilding the game module regenerates them with its declarations;
the module ABI rejects an older layout.

**Editing remains disabled.** `live_target.zig` is the future write boundary:
field targets contain World identity, actor generation, component membership
revision, and field identity, never a borrowed component pointer or document
binding. It rejects stale targets, non-Play Worlds, writes during callback borrows,
and authoring fields marked read-only. Its field write adapters use runtime
setters/generated accessors and normal change notifications. Hierarchy/tag editing
and nullable built-in edit controls still need their UI policies when editing is
enabled. There is no document/history write in this path; Stop discards the Play
World and returns to the unchanged Edit World. Future widgets must call this
boundary between ticks, using the active Play module binding.

#### Typed built-in field identities

Each engine component module exports a generated `Field` enum, for example
`render.Field` and `animation.Field`. `builtin_component.FieldEnum` derives its
names from the reflected descriptor (including flattened material properties)
and synthetic authoring rows. Structural/picker policies use those enums too;
a misspelled field is a compile error. Presentation rows keep canonical names,
validated against the descriptor, rather than maintaining another field enum.

`scene/components/fields.zig` generates a tagged union whose payload is the
owning component's enum. This keeps a component and its field inseparable:

```zig
const field: builtin_fields.Field = .{ .render = .material };
const value = builtin_fields.read(entity, field, 0);
try scene_edit.applySceneActorTypedField(
    world, actor_id, .{ .animation = .speed }, 0, .{ .number = 2 },
);
```

These are engine-internal APIs; gameplay keeps the SDK's existing typed
component helpers. The inspector resolves a name when binding a row, then
retains the typed identity for reads and future edits. Reflection accessors are
selected at compilation; refreshing a built-in value does not search field names.
Each owning component implements `readField`, `liveTarget`, and any
`writeStructural`/`fieldChanged` hooks. World code owns locking and notifications.

Scene JSON, scripting and tooling still use names. `fields.fromName` is their
conversion boundary; unknown names are rejected (`null`, or `InvalidField` from
the World edit API). Enum ordinals are internal and must never be persisted or
sent over a wire. Renaming an authored field still requires an intentional schema
change. Both the enums and dispatch are generated by Zig compilation; no extra
code-generation step or generated source file is needed.
