# Projects, scenes, prefabs and assets

Sindri AI knowledge pack, file `05-projects-scenes-prefabs.md`. What a project is (`sindri.toml`), how a `.scene` file is shaped and versioned, how asset paths resolve, prefabs, profiles, and the component registry.

Start with `00-start-here.md` if you have not read it. Every fact here is copied from the Sindri repository; nothing in this file is a guess.

## Contents of this file

- **Project format** (from `docs/project-format.md`): The file; What creating one makes; File extensions; The project root and the asset root are two directories; Which project a launch opens; The welcome window; What this does not do yet; External static models
- **Scene serialization and round-tripping** (from `docs/scene-serialization.md`): The round-trip contract; Stable IDs are never regenerated; Canonical form; Editor-only metadata; Versioning and migration
- **Component schema registry** (from `docs/component-schema-registry.md`): Fields and defaults are different questions; Unknown components; Queries; Saving runtime prefab references; Authoring entity references
- **Prefabs** (from `docs/prefabs.md`): Why there is one; What one is; Spawning one; Loading one; Placing one in a scene; In the editor; What is deliberately not here
- **Profiles** (from `docs/profiles.md`): File format; Editor authoring; Decay access; Loading and export
- **Asset foundation** (from `docs/asset-foundation.md`): Logical identity; Typed runtime handles; State and error model; Asset sources; Asynchronous load queue; Typed decoding; Assets that describe other assets; Driving all of it; Reloading; Decoding the same way everywhere; What a project ships; Deliberate boundaries; Model delivery
- **Runtime ambient and background** (from `docs/runtime-environment.md`)

---

Source: `docs/project-format.md`

## Project format

A Sindri project is a directory containing `sindri.toml`.

That file is the whole of the format. Its presence is what makes a folder a
project rather than a folder with a scene in it, which is the distinction the
editor had no way to draw: it opened a scene file, showed whatever directory
that file happened to sit in, and called it the project.

### The file

```toml
format_version = 1

[project]
name = "Gather"
main_scene = "assets/gather.scene"
scenes = ["assets/house.scene"]
```

Four fields, and each is read by something today.

- `format_version` describes the file rather than the project, which is why it
  sits above the table — TOML puts bare keys before the first table, so a
  version inside `[project]` would be a field order that serializes into a file
  it cannot read back. A manifest whose version is higher than the editor
  understands is **refused**, not guessed at: opening it would mean ignoring
  whatever the newer field said, and a project that quietly loses a setting is
  worse than one that says it needs a newer editor.
- `name` is what the project is called. Stored rather than taken from the
  directory name, because the two are not the same thing: the companion game is
  called Causeway and lives in a folder called `game`. It is what the welcome
  window lists and what the project browser's header shows.
- `main_scene` is the scene opening the project opens, relative to the root and
  written with forward slashes so a checkout on another platform still finds it.
  Set from the project browser — **Set as main scene** on any scene row that is
  not already it — and claimed automatically by a scene made in a project that
  nominates none, which is never an overwrite: a project that already opens on
  something has been decided about.
  Optional, because a project can legitimately have no obvious first scene, and
  a nominated scene that has been deleted opens **nothing** rather than some
  other scene that happens to be nearby — standing one in for the other reads as
  though the named one loaded.

- `scenes` are the other scenes the project can reach, written the same way.
  Optional and usually absent: most projects have one place in them.

  Declared rather than discovered, which is the only list the exporter takes and
  is worth justifying. A scene reached by `Scene.go("house")` is a string inside
  a program, and the exporter deliberately does not look for strings that
  resemble paths — a field declared `String` is text however much it looks like
  one, which is the same reason prefabs are found through a script's declared
  types instead. Nor is `[assets] include` the place for them: that ships raw
  bytes, so a scene listed there would arrive without its own textures, scripts
  or prefabs, which is an export that looks complete and opens a door onto
  nothing.

  Listing the main scene here as well is allowed and changes nothing; it ships
  once.

  The editor's **Scenes** panel is this list as a board: a card for the main
  scene and one for each scene here, in order, with the doors between them
  drawn as arrows from the `Scene.go("…")` and `Scene.restart("…")` calls in the scripts each scene
  runs. Its **Add scene** menu, and a card's **Remove from project**, **Move
  earlier**/**later** and **Set as main scene**, write this field. Setting a
  listed scene as main puts the old main scene at the head of the list, so no
  scene stops shipping because another one was chosen. A script that goes to a
  scene this list does not carry is marked on its card, because a build would
  have nowhere to go.

  The editor writes the manifest by editing the file in place: a table it does
  not model, such as `[web.splash]`, and comments survive any of these.

`PROJECT_OVERVIEW.md` sketches a larger file: window size, feature flags, an
asset root, a web canvas selector. None of that is here, and the sketch itself
says why — "avoid designing an enormous configuration schema before features
require it". The next field arrives with the feature that reads it.

`sindri.toml` is not `sindri.manifest`. The manifest is an asset ledger of
bytes and hashes, written by a build and verified by a loader; this is project
metadata, written by a person or by the editor.

#### `[web.splash]`

A browser build's own brand, shown after the Sindri loading screen while the
game loads: `image`, `title`, `caption`, `background`, `seconds` and
`entrance`.
`docs/export.md` has what each means and how long it shows.

### What creating one makes

New Project writes a directory holding the manifest, a scene, and the folders
assets are put in:

```text
my-first-game/
├── fonts/
├── scripts/
├── textures/
├── main.scene
└── sindri.toml
```

The asset folders sit beside the scene rather than under an `assets/` root
because that is where asset references actually resolve today:
`SceneTextures::for_scene` roots the loader at the scene's own directory. A
layout that looked tidier and loaded nothing would be worse than a flat one. A
project laid out differently — Gather keeps its scene under `assets/` — is
opened exactly the same way; this is what the editor *creates*, not what it
requires.

The scene it writes is the one New Scene writes: one world camera, from the
component registry's own default payload. A second copy of that answer living
beside the project format is a copy that drifts.

A directory that already holds a `sindri.toml` is refused rather than
overwritten. A directory holding other files is allowed, and the form says so
before the button is pressed.

### File extensions

Every file the engine authors has an extension of its own, named for what it
is rather than for the notation inside it:

| Extension | What it is |
| --- | --- |
| `.scene` | a scene document |
| `.prefab` | a prefab |
| `.profile` | a data profile |
| `.sheet` | the sprite sheet that slices the texture of the same stem |
| `.tileset` | a tile set |
| `.actions` | an input action map |
| `.isobake` | an isometric baker recipe |
| `.decay` | a Decay script |
| `.weave` | a Weave stylesheet |
| `sindri.manifest` | the asset ledger a build writes |

Most of these hold JSON today, and their loaders still parse them as JSON, but
the extension is the format's name: the project browser, the export gatherer,
the asset manifest and decay-lsp all decide what a file is by it, and a plain
`.json` in a project is just a file. There is no fallback to the old
`*.scene.json`-style names; a project written before the change is migrated by
renaming its files (`level.scene.json` to `level.scene`) and the references to
them.

### The project root and the asset root are two directories

A project is rooted at its `sindri.toml`. Asset references are not: a scene
names its textures, scripts, fonts, and clips relative to **the directory the
scene file itself is in**, because that is where `SceneTextures::for_scene`
roots the loader.

For a project the editor creates those are the same folder, and the distinction
never shows. For Gather they are two folders apart:

```text
game/                       <- the project root: sindri.toml, Cargo.toml, src/
└── assets/                 <- the asset root: where references resolve
    ├── gather.scene
    └── textures/orb.png    <- the scene names this "textures/orb.png"
```

`assets/textures/orb.png` is that file's path from the project root and is not a
reference to it. Writing it into a texture field names a file the loader will
look for at `game/assets/assets/textures/orb.png`, which is nothing, and the
sprite draws the missing checker.

So the browser knows both. `ProjectEntry::relative` is the path below the root,
which is what a search result shows to tell two files of the same name apart;
`ProjectEntry::reference` is how a scene names the file, which is what every
picker offers, what **Copy asset path** copies, and what the inspector checks a
typed reference against. A file outside the asset root has no reference at all —
Gather's `src/main.rs` is a real file that no component can name — and is
offered by nothing rather than offered under a path that will not resolve.

The browser lists the asset root by default for the same reason. A project's
Cargo manifest and its `src/` are part of the project and are not part of what
an editor field can point at, and a panel two thirds full of rows whose paths
mean nothing is a directory listing rather than an asset browser. The rest of
the project is one control away in the browser's toolbar, and the choice is
remembered; the control is drawn only where the two listings actually differ.

### Which project a launch opens

In order of how deliberately it was asked for, which is the ordering
`scene_io` already applied to scenes:

1. **A path on the command line.** A directory holding a manifest opens as a
   project; anything else opens as a scene, including a path to nothing — a
   named file that is missing is a failure the editor reports, never a reason to
   open something else.
2. **The last project**, when the user asked for that. The welcome window's
   footer is the only place that preference is set.
3. **The welcome window**, which asks.

A scene carries its project with it. Opening one — from the command line, from
a file dialog, from a browser row — walks up from the file to the nearest
`sindri.toml`, so a scene inside a project opens *as* that project: the browser
is rooted at the project and headed with its name, and lists the assets inside
it. A scene in no project leaves the editor with none, which is the state it was
always in before projects existed and is still a perfectly good way to edit one
file.

Opening a project opens the scene the editor was last left in when that scene is
inside it, and `main_scene` otherwise. Reopening a project should put someone
back where they were working rather than at its front door.

### The welcome window

Its own window, and the editor's is hidden until a project is open. It is not
part of editing anything: the editor's window is about a scene — its title
carries that scene's name, its panels hold that scene's entities, its viewport
renders it — and a "no project open" state painted over all of that would be an
editor pretending to be a launcher.

It lists the projects that have been opened, most recent first, twelve at most.
A project that has moved or been deleted is **shown and marked missing** rather
than quietly dropped: silently pruning the list answers "where did my project
go" with an empty row where it used to be, and the editor cannot tell an
unmounted volume from a deletion. A row leaves only when someone asks it to, and
removing it touches nothing on disk.

Each row is remembered by path and shown by name, with the name stored beside
the path. Reading every remembered project's manifest to draw the list would
mean a file read per row per frame, and a project on a disconnected network
drive would hang the window rather than appear in it. The name is re-read
whenever a project is opened, so renaming a project in its manifest shows up
next time it is opened.

Each row also says when the project was last opened ("2 days ago"), recorded
when it is opened rather than read from the disk, and a search box filters the
list by name or folder. A list saved before times were recorded still reads;
its rows show no time until each project is opened again.

Beside the list are the two ways to get a project that is not on it — New and
Open — a Learn panel linking to the guides in `docs/`, and the projects this
repository ships as Examples, listed only when they are actually there.
`SHIPPED` is relative to the working directory, which is the repository root
under `cargo run` and is somewhere else entirely for an installed editor: a
sample row that fails on the click is worse than no sample row. The Learn panel
opens the local copy of a page when the repository is beside the editor and
the GitHub copy otherwise.

The banner and the tiles beside each project are drawn, not loaded: the editor
has no image loading yet, so a project's tile is its initial on a colour derived
from its name. Screenshot thumbnails need a cached capture per project and are
not done yet.

What it deliberately is not is Unity Hub. There are no editor versions to
install, no account, and no news, because Sindri has none of those things to
manage.

#### How the window exists

A deferred egui viewport, which eframe's wgpu integration opens as a real second
window. Deferred rather than immediate because an immediate child viewport is
drawn by its parent, and the parent here is the hidden editor: eframe throttles
a hidden window to ten frames a second so that a `Visible` command still reaches
it, which would make the one window the user can see repaint at the rate of the
one they cannot.

The editor's window starts hidden and is revealed when a project opens. That is
also what makes closing the welcome window with no project open close the
editor: the alternative is a running process with nothing on screen and no way
back to it.

Two consequences worth knowing:

- The welcome window is titled "Sindri" and not "Sindri Editor", because
  `scripts/capture-editor.sh` finds the editor by matching a title ending in the
  latter. That script also names the demo scene explicitly now — a launch with
  nothing on its command line opens the welcome window, which is right for a
  person and wrong for a screenshot.
- Where multiple windows are unavailable, egui embeds a child viewport inside
  its parent. The editor checks for that and shows its own window, so the
  welcome window is not painted somewhere nobody can see. On Wayland,
  `set_visible(false)` is not honoured, so the empty editor window is visible
  behind the welcome window rather than hidden; nothing else changes.

### What this does not do yet

- One scene at a time. A project can hold many scenes and the Scenes panel
  shows them all, but the editor edits one of them at a time.
- Only half a settings surface. **Set as main scene** in the project browser
  nominates what a project opens on, and a scene made inside a project that
  nominates nothing claims the empty place. The project's *name* still cannot
  be changed from the editor — a project is renamed by editing the file.
- The runtime and exporter read the manifest's entry scene and asset policy,
  but there is no broader runtime settings surface yet: window policy, host
  module selection, and target-specific settings are not project fields.

### External static models

An entity can reference a self-contained GLB with
`"sindri.model": { "asset": "models/crawler.glb", "layer": 0 }`. The asset ID is
relative to the project's asset root; models remain separate files. Export finds
references in scenes and prefabs automatically, validates and preserves their
bytes, and records manifest kind `model`. Native project capture and the exported
WebGPU host load these references through the normal asset pipeline. See
imported models (in `09-3d.md`) for supported features and diagnostics.

---

Source: `docs/scene-serialization.md`

## Scene serialization and round-tripping

A Sindri scene is a versioned JSON document. It is the format an editor saves, a runtime loads, and
a reviewer reads in a pull request, so it has to survive all three without drifting. This document
describes the guarantees the format makes.

### The round-trip contract

```rust
let document = SceneDocument::from_json(&text)?;   // parse and validate
let loaded = World::from_scene(&document)?;        // logical IDs -> runtime handles
// ... edit the world ...
let saved = loaded.world.to_scene()?;              // runtime handles -> logical IDs
let text = saved.to_canonical_json()?;             // canonical bytes
```

Loading and saving an unmodified scene reproduces the file byte for byte. Golden fixtures in
`crates/sindri-core/tests/fixtures` enforce this: they are stored in canonical form, so any change
to ordering or formatting fails the test suite instead of quietly rewriting everyone's scenes.
Regenerate them deliberately with:

```bash
SINDRI_UPDATE_SCENE_FIXTURES=1 cargo test --workspace
```

### Stable IDs are never regenerated

Serialized entities are identified by a project-authored `SceneEntityId`. Runtime `EntityId` handles
are generation-checked slots that change as entities are spawned and destroyed, and they are never
written to disk. `World::to_scene` writes back each entity's original ID, so saving does not
renumber a scene or invalidate references to it.

An entity spawned at runtime has no stable ID. Rather than inventing one during a save or dropping
the entity, `to_scene` reports `WorldError::UnstableEntity`. Tools that create entities call
`World::assign_missing_source_ids` first, which mints IDs in entity order and skips identities
already in use, so the same world always produces the same assignment.

### Canonical form

`SceneDocument::to_canonical_json` produces the one representation a document is stored as:

- entities sorted by stable ID;
- object keys sorted, including inside component payloads the engine does not interpret;
- absent names, parents, transforms, components, and editor sections omitted rather than written as
  `null` or `{}`;
- arrays of scalars kept on one line when they fit within 96 columns, so a three-component position
  is one reviewable line rather than five;
- two-space indentation and a trailing newline.

Serializing an already canonical document is a fixed point, so repeated saves stop producing diffs.

Document order carries no rendering meaning. Draw order comes from explicit render layers and from
where things are relative to the camera (see transparent rendering (in `06-2d.md`)),
which is why sorting entities is safe: it keeps saves stable while entities are added, removed, and
reparented.

A transform may also declare `z_locked`, which says that what layer it is on is
deliberate. It is omitted when false, so a scene that declares nothing writes
nothing and a scene written before the lock existed is byte for byte what it
was. See how Sindri does 2D (in `06-2d.md`) for what respects it.

An entity may also declare `disabled`, which says it takes no part in the scene:
nothing it carries is drawn, stepped, scripted or picked, and neither is
anything under it. It stays in the document — that is the difference between
switching something off and deleting it. Omitted when false, for the same reason
`z_locked` is. `World::is_active` is the question a runtime asks, and it walks
ancestors: the flag is never written down through a subtree, so switching a
parent back on brings back exactly the children that were on.

An entity may be a prefab **instance**, declaring `prefab` — the prefab's asset
ID, the overrides this instance made and the prefab entities it removed — and
no `components` of its own. It is
omitted from every other entity, so no format version changed when it arrived,
as none did for `disabled`. A saved instance is written as that reference
rather than as the entities it expands to, and its overrides are the
difference from the prefab, so saving an instance nobody edited reproduces it
exactly. Prefabs (in `05-projects-scenes-prefabs.md`) has the format.

Loaded scene roots retain their runtime namespace, independently of their display
name, so qualified references resolve after loading under a new namespace. This
bookkeeping is not serialized. Library-aware scene loading retains original prefab
root aliases as runtime link metadata, namespaced with scene IDs. They are omitted from serialized links and
scene files; saving instances as references and reopening regenerates them from
the library. Flattening with `SceneDocument::expanded` discards this metadata,
so hosts with prefab-root references load original documents through
`World::from_scene_with`, `World::add_scene_with` or `LoadedScenes`' library-aware
variants. After assigning IDs to runtime spawns, use
`World::to_scene_with_references(prefabs, components)` to remap registered entity
fields before serialization. It uses registry `FieldMeaning::Entity` metadata,
including nested lists, leaves the live world untouched and rejects nonempty
unresolvable or unstable targets and malformed reference fields. Empty references
remain unbound; unknown/undescribed fields remain unchanged. Ordinary `to_scene`
and `to_scene_with` still serialize payloads verbatim.

Scenes reject non-finite transform values. JSON has no `NaN` or `Infinity` literal, so a scene
containing one could not be read back; validation catches it at the point it is introduced instead.

### Editor-only metadata

Tooling state lives in namespaced `editor` sections — one on the document and one on each entity:

```json
{
  "format_version": 1,
  "metadata": { "name": "Room", "editor": { "grid_snap": 0.25 } },
  "entities": [
    { "id": "player", "editor": { "collapsed": true } }
  ]
}
```

Runtimes carry these sections through a load and save unchanged but never interpret them, so a scene
loads identically with or without them. Export pipelines drop them with
`SceneDocument::strip_editor_metadata`.

Unregistered component payloads get the same treatment for the same reason: an older tool preserves
data a newer one authored. See the component schema registry (in `05-projects-scenes-prefabs.md`).

### Versioning and migration

Every document declares `format_version`. The current format is `SCENE_FORMAT_VERSION`, and a
runtime rejects any other version rather than guessing at its meaning.

`SceneMigrator` existed before format version 2 did, so each real format change has been a
registration rather than a redesign:

```rust
let mut migrator = SceneMigrator::new();
migrator.register(1, 2, upgrade_v1_to_v2)?;
let document = SceneDocument::from_json_migrated(&text, &migrator)?;
```

Migration steps operate on raw JSON, because an old document cannot by definition be deserialized
into the current `SceneDocument`. The migrator enforces the properties that keep a chain honest:

- steps must move strictly forward, so a chain cannot loop;
- steps may not target a version this runtime does not support;
- one step per source version;
- the target version is stamped by the migrator, not by each step;
- a document newer than this runtime is rejected as such, and a document older than any registered
  step reports the missing migration by version.

Documents already at the current version pass through untouched, so a migrator with no registered
steps behaves exactly like plain parsing.

---

Source: `docs/component-schema-registry.md`

## Component schema registry

Scene component payloads remain JSON so a newer editor or runtime can preserve data that an older
tool does not understand. Code that needs to interpret a component registers a Rust type with
`ComponentSchemaRegistry` by implementing `SceneComponent`.

```rust
#[derive(serde::Deserialize)]
struct Health {
    current: u16,
    maximum: u16,
}

impl SceneComponent for Health {
    const TYPE_NAME: &'static str = "game.health";
}

let mut components = ComponentSchemaRegistry::default();
components.register::<Health>("Health")?;
```

Each registration records a stable type name, editor-facing display name, and schema version. The
registered Rust type supplies payload validation and typed decoding.

### Fields and defaults are different questions

A registration may also record two payloads, and conflating them is a mistake the editor already
made once.

The **field template** is what the component *has*: every field, at the value it takes when nobody
has said. A tool draws a component by filling this out with whatever the instance stored, so the
same component shows the same rows however it was authored.

The **default payload** is what a *fresh* one is. Only some types have one. A component naming a
font, a sheet, another entity, or a clip has no blank the engine can invent, and a button that adds
a component the engine then rejects is worse than no button.

```rust
// Fields and a fresh blank, which for most types are the same object.
components.register_with_default::<Health>("Health", json!({ "current": 10, "maximum": 10 }))?;

// Fields but no blank: the host completes it from whatever it has.
components.register_with_fields::<Portrait>("Portrait", json!({ "image": "", "scale": 1.0 }))?;

// Neither: readable and editable, and drawn from whatever its payload carries.
components.register::<Mystery>("Mystery")?;
```

`register` is for a component nothing has described. Prefer one of the other two for anything an
editor will draw: without a field template a panel shows the fields the payload happens to carry,
so one added by a button and one authored by hand become different-looking components.
`sindri.ui.text` was registered that way and inspected as two rows for a seven-field component;
`sindri.tilemap`'s default omitted `projection`, so a tilemap made in the editor could never be
isometric.

Both payloads are checked at registration against what serde will actually ask the type for, so a
template that has drifted from its struct is a startup error rather than a row quietly missing from
a panel a release later. The check is skipped for a component that is not a struct — an internally
tagged enum like `sindri.camera` has fields decided by its variant.

### Unknown components

Callers choose unknown-component behavior explicitly:

- `UnknownComponentPolicy::Preserve` validates known payloads and leaves unknown JSON untouched.
  This is the default compatibility policy for scene round-tripping and editor workflows.
- `UnknownComponentPolicy::Reject` reports the entity and unknown type. This is appropriate when a
  runtime proof requires every component to be actionable.

Known components are always schema-validated under either policy.

### Queries

`ComponentSchemaRegistry::query::<T>(&world)` returns typed `(EntityId, T)` values for every entity
carrying the registered component. Invalid runtime payloads produce an error naming both the entity
and component type rather than being silently skipped.

The combined cube/sprite example registers camera, mesh, and sprite component types, validates its
embedded scene in strict mode, and uses the typed query API during frame extraction.

### Saving runtime prefab references

`World::to_scene_with_references(prefabs, components)` uses fields described as
`FieldMeaning::Entity` to remap local runtime-prefab paths to assigned stable IDs.
Dotted paths and `[]` lists follow the registry's field-path grammar. Describe
entity fields explicitly: unknown components and undescribed strings are preserved
verbatim. The method leaves the live world unchanged and rejects nonempty missing
or unstable targets and malformed reference fields; empty text stays unbound.
Ordinary world serializers retain their verbatim contract.

### Authoring entity references

Fields described as `FieldMeaning::Entity` get a typeable entity picker at every
object/list depth in the native inspector. Choices include stable IDs and names
within the owner's scene, or canonical local paths within one runtime prefab.
An explicit None choice clears the field. Missing targets and inactive targets
are distinguished visibly; unresolved text is retained for later repair.
`World::resolve_entity_reference` supplies the same canonical/alias, namespace
and prefab-scope rules used by authored physics joints. It resolves inactive
entities for diagnostics; physics separately requires active endpoints.
The inverse `World::entity_reference(owner, target)` chooses a scoped authored
string for a handle and returns none for unstable/out-of-scope targets.
Edits remain schema-checked component commands with undo/redo.

---

Source: `docs/prefabs.md`

## Prefabs

An authored reusable entity definition, and the thing `World.spawn` creates.

### Why there is one

A script could find another entity, reach through it, check whether it still
existed, and remove it. It could not make one. That was not an oversight —
`docs/scripting.md` recorded it as a real dependency: creating an entity means
saying *what* to create, and the engine had nothing to say it with.

So a game could only ever have the entities its scene was authored with. Every
enemy, bullet, pickup, and effect had to be placed by hand before the run began.
`docs/orbital-last-stand-audit.md` found this first among the reasons a
real-time action game could not be authored through the editor and Decay.

### What one is

A scene fragment with exactly one root.

```json
{
  "format_version": 1,
  "metadata": { "name": "Bullet" },
  "entities": [
    {
      "id": "bullet",
      "name": "Bullet",
      "transform_3d": { "position": [0.0, 0.0, 0.0] },
      "components": {
        "sindri.script": { "source": "scripts/bullet.decay", "script": "Bullet" },
        "sindri.sprite": { "texture": "textures/bullet.png", "layer": 3 }
      }
    }
  ]
}
```

That is a `SceneEntity`, unchanged, and deliberately: a prefab reuses the
scene's entity shape, its component payloads, its identities, its canonical
serialization, and its validation. **It is not a second document format.** A
separate shape for "a subtree of entities" would be a copy of the scene format
that drifts from it, and a prefab that could not hold a component a scene can
hold would be a trap discovered late.

The rules a scene's entities obey are in `scene::graph`, shared by both, rather
than written twice — the second copy of a validator is the one that stops
matching the first.

#### The one rule a prefab adds

**Exactly one root.** A prefab with several is refused when it is read.
`World.spawn` answers with a reference, and a document with two roots would
make it answer with one of them and leave the other attached to nothing an
author can name.

#### Its own format version

`PREFAB_FORMAT_VERSION` is not `SCENE_FORMAT_VERSION`. The two documents share
an entity shape and will share its migrations, but they are separate files with
separate histories: a prefab gaining a document-level field is not a reason to
step every scene in a project. A version this runtime does not understand is
refused rather than guessed at, exactly as a scene's is.

### Spawning one

From Decay, `World.spawn(prefab)` creates a root-level instance.
`World.spawn_child(prefab, parent)` creates the same prefab and attaches its root
to an existing entity before the spawned script can start. The prefab root's
authored transform remains local to that parent, and the prefab's own descendants
remain under its root. A stale parent is refused before anything is created.

The full contract — that a prefab reference is a typed `Prefab` rather than text,
how overrides work, when a spawned script's `start` runs, and what the bounds are
— is in `docs/scripting.md`, because it is a statement about the scripting
surface.

From Rust, `World::spawn_prefab` returns a `SpawnedPrefab`: the root, every
entity created, and which authored identity each became.
`World::spawn_prefab_from` does the same for a prefab with others nested in it,
given the library to make them from; a nested entity is keyed by its path,
`loot/sparkle`. `World.spawn` in Decay does this against the project's
prefabs, and counts what the nesting expands to against the spawn limit.

This is not how a scene places one. A spawn makes the prefab's entities once,
for a world that will not be saved; a scene's instance keeps the reference —
see Placing one in a scene.

#### A spawn has no stable identity

Spawned entities carry no `source_id`. A prefab's identities name entities
*inside the prefab*; two instances carrying them would collide on every one, and
a scene saved with the collision would refuse to load.

`World::assign_missing_source_ids` remains how a runtime entity earns a stable
identity. That is a decision about persisting a world, not about spawning, and
keeping the two apart is why saving a world full of bullets is a thing you ask
for rather than a thing that happens.

#### Runtime-local reference identity

Each spawned entity also carries a `PrefabIdentity`: the generation-checked
root handle of this spawn, its path in the expanded prefab, and namespaced
aliases for original nested roots renamed during expansion.
`World::prefab_entity(owner, path)` searches the owner's enclosing path namespaces,
then the prefab's own namespace. It includes the original root ID and never
falls through to another instance or a scene entity. It returns disabled entities
too, allowing callers to suspend a reference without rebinding its name.
Removing the root invalidates the scope even if its slot is reused. Reparenting,
world cloning, command undo and assigning stable IDs retain this runtime identity.

This identity is independent of `source_id` and the editor's `PrefabLink`.
Neither it nor its runtime handle is serialized. Saving assigned scene IDs does
not rewrite component-local references automatically. To persist spawned mechanisms,
call `World::to_scene_with_references(prefabs, components)` after assigning stable
IDs. It remaps fields described as `FieldMeaning::Entity`, including dotted paths
and `[]` lists, to their target's assigned scene ID. Inactive targets still resolve;
empty references remain unbound. Nonempty missing/stale local targets, unstable
targets and wrong-type fields fail the save. Serialization works on a clone and
never edits the live world. Undescribed fields and unknown component payloads
are preserved unchanged; describe every entity field that needs remapping.
`to_scene`/`to_scene_with` retain their existing verbatim serialization contract.
Editor `SceneFile::save`/`save_as` and subtree-to-prefab authoring automatically
use the reference-aware serializer with the editor's active component registry.
Stable IDs must already exist; failed reference validation leaves disk, the
adopted path and the agreed document unchanged. Existing placed instances still
collapse to prefab references. Save remains unavailable during Play, and Decay's
`Save` number/flag store is separate from world snapshots; script-triggered world
save/load is not implemented. The platformer's real Decay-spawned nested windmill
exercises editor Save As, reopen and reversal around its fixed axle.
Runtime spawning preserves nested root aliases through the original prefab library, including repeated expansion.
Canonical paths take precedence over aliases, including inactive targets; competing
aliases without a canonical target are rejected before any entities are spawned.
Placed instances carry the same original root aliases in runtime `PrefabLink`
metadata. `LoadedScenes` has library-aware `load_with`/`enter_with` and
`load_keeping_identities_with`/`enter_keeping_identities_with` variants. Scene
entry expands original documents through `World::add_scene_with`, prefixing
aliases with the scene namespace. The native/browser project hosts retain the
original scene and library instead of feeding a flattened document to entry.
Loaded scene roots retain their runtime namespace so qualified saved IDs resolve
inside that scene after namespacing. Editor reload updates aliases through checked
link commands; duplication rebases aliases to the copy's instance namespace. Undo preserves them. Saving instances
as prefab references and reopening regenerates aliases from the library; aliases
are never serialized. `SceneDocument::expanded` remains useful for validation,
but its plain entities discard reference metadata and should not replace
library-aware loading when prefab-root references are needed.

The platformer uses this engine capability in a reusable powered windmill:
Decay places/removes it with V, and its authored hinge resolves its own axle and
rotor, including the prefab root renamed inside `windmill-kit.prefab`. The level
windmill is a placed instance of that same nested assembly.

#### Editor-only state does not come along

A prefab's `editor` sections describe the prefab in the editor — what is folded,
what is selected. An instance in a running world has no use for them, and
carrying them would put a fold state on every bullet.

#### Nothing is half-created

The document is validated before anything reaches the world. A prefab with
several roots, a missing parent, or a non-finite transform spawns nothing at
all rather than leaving a partial subtree behind.

### Loading one

`.prefab`, resolved against the scene's directory like every other asset,
and delivered by the same text pipeline the scripts use — a prefab is JSON, and
the pipeline has no reason to know more than that. A document that will not
parse is reported once, when it loads, rather than on the frame a script spawns
it.

Which prefabs a scene needs is answered by the *declared type* of each script's
exported fields: a field declared `Prefab` is a prefab reference, and one
declared `String` is text however much it looks like a path. That is what makes
a scene's prefabs loadable before the first frame instead of discovered when a
script spawns.

Because the declared types are not known until a source has compiled, the set is
asked for every frame rather than once at open — which is also what makes a
prefab authored a moment ago load a moment later.

### Placing one in a scene

A scene places a prefab as an **instance**: an entity that names the prefab
and says only what is its own.

```json
{
  "id": "coin-3",
  "name": "Coin 3",
  "parent": "pickups",
  "transform_3d": { "position": [31.5, 6.0, 0.2] },
  "prefab": {
    "source": "prefabs/coin.prefab",
    "overrides": {
      "coin": { "components": { "sindri.sprite": { "layer": 6 } } },
      "sparkle": { "disabled": true }
    }
  }
}
```

The instance keeps its own ID, parent, name, transform and switch — those are
the prefab root's, as placed. Everything it is made of is the prefab's, so an
instance carries no `components` of its own; a scene that gives it some is
refused, because two places saying what an instance is made of is one place
too many.

When the scene loads, the instance becomes the prefab's entities. Its root
keeps the instance's ID; every entity under it is `<instance>/<id in the
prefab>`, so ten coins never collide and a scene can hang an entity of its own
under a prefab's child by naming `coin-3/sparkle` as its parent.

This is the one way a prefab changes a scene after it was placed: the scene
holds the reference, so an edit to `coin.prefab` is an edit to every coin the
next time the scene is opened, and at once in an editor that has it open.
`games/platformer` places its ten coins this way.

#### Overrides

`overrides` is keyed by the prefab's own entity IDs, root included. Each says
what that entity's instance changed:

- `name`, `transform_3d`, `disabled` — written whole when they differ.
- `components` — a JSON merge patch (RFC 7386) per component: an object merges
  field by field, `null` removes the component (or a field), and any other
  value replaces it. A component the prefab lacks is added whole.
- A list that changed in a few places is patched by index rather than
  replaced, so an instance that changes one tile of its prefab's tilemap says
  only that tile: `{ "$items": { "57": 3 }, "$length": 120 }`. `$items`
  patches the elements it names (an object merges into an object element,
  anything else replaces it) and `$length`, when present, cuts the list short
  or pads it with `null` first. A list that changed in most places is written
  whole. Keys beginning with `$` are reserved inside a patch for this.
- `editor` — the entity's editor-only state in this instance, such as where it
  sits among its siblings. Runtimes ignore it, and shipping strips it.

`removed` lists the prefab's entities an instance does without, by the same
keys; everything under a removed entity goes with it. The root cannot be
removed.

Overrides are never tracked as they are made. Saving works each one out as the
difference between what the instance's entities are and what the prefab says,
so setting a value back to the prefab's stops being an override without anyone
having to say so, and an instance standing exactly where its prefab's root
does writes no transform. A key the prefab no longer has is dropped the next
time the scene is saved.

Saving works `removed` out the same way, from which of the prefab's entities
are missing. An instance can do without an entity but cannot hang one
somewhere else, so the editor refuses to move or rename an entity inside an
instance, and a world where one was moved anyway — or where an entity survives
under one that is gone — fails to save, naming the instance
(`WorldError::InstanceReshaped`). Unpacking the instance is the way out.

#### Nested prefabs

A prefab is made of the same entities a scene is, so a prefab may place an
instance of another. A chest prefab can hold a coin; placing the chest makes
both. An override reaches inside by path — `loot/sparkle` is the `sparkle` of
the chest's `loot` coin. A prefab that contains itself, directly or not, is
refused naming the loop, and nesting stops at `MAX_PREFAB_NESTING` (16).

This is nesting, not variants: a prefab cannot be "a coin, but gold". That
remains a deliberate anti-goal in `docs/parity.md` until a game proves it
needs one; an instance with overrides covers the case so far.

#### Loading a scene with instances

Core does no I/O, so every host hands it a `PrefabLibrary` — anything that
answers "which prefab is this asset ID":

- `World::from_scene_with` and `World::add_scene_with` expand instances and
  give every entity they make a `PrefabLink` (the prefab, the entity's path in
  it, and whether it is the root). `World::to_scene_with` writes instances back
  as references. The editor loads this way, because it saves.
- `SceneDocument::expanded` returns the scene with every instance made into
  plain entities. A host that plays a scene and never saves it uses this once
  and then loads through the paths it already had. The browser host does.
- `World::from_scene` and `World::to_scene` keep working for a world with no
  instances, and name the missing prefab when given one rather than loading an
  instance as nothing.

The exporter reads every prefab a scene places, and every one nested in those,
before it walks the scene, and ships them as prefabs: a placed coin's sprite is
named in the coin's prefab, not in the scene.

### In the editor

- **Placing.** Drag a prefab from the project browser into the Scene view: it
  lands on the grid cell under the pointer when there is one, and otherwise
  where the pointer meets the plane a 2D scene is drawn in, at the prefab's own
  depth. Choosing a prefab opens its panel, where *Add to scene* places one in
  the middle of the view and *Place on a cell* arms clicking. Each makes a
  linked instance as one undo step. A prefab outside the scene's folder cannot
  be placed, because the scene would have no asset ID to name it by.
- **Seeing it.** An entity of an instance shows *Instance of coin.prefab* at
  the top of the inspector, with what it overrides listed below and a way to
  revert each row; the root also lists the entities the instance does without,
  each with a way to bring it back.
- **Deleting inside it** removes that entity from this instance only. Moving
  or renaming one is refused.
- **Revert all** puts the instance back to exactly its prefab, removed entities
  included; **Apply** writes what it overrides and removes into the prefab file
  and brings every instance up to date; **Unpack** keeps the entities and cuts
  the link.
- **Making one.** *Make prefab* on a hierarchy row writes that entity and
  everything under it to `prefabs/<name>.prefab` beside the scene — an
  instance inside it stays one, nested — and replaces it with an instance. On
  a selection it makes a prefab of each, as one step.
- **Undo reaches the files.** Apply and Make prefab write files, and Undo and
  Redo write them back — or take away a file the edit made — as they step
  across the edit, unless the file was changed on disk since, which is left
  alone and said so.
- **Editing one.** *Edit prefab* opens the prefab as the document being edited,
  in the same panels a scene uses. Its asset IDs resolve against the scene's
  folder, as they do when it is placed. Saving writes a prefab, and refuses a
  second root. *Back to …* returns to the scene it was opened from.
- **Following it.** When the file watcher reads a changed prefab, or Apply
  writes one, every instance in the open scene is written down against the
  prefab it was made from and reconciled with what it now expands to. That is
  done with commands, so overrides survive, the entities keep their handles and
  the selection, and the update is one undo step. A prefab changed while the
  scene is playing is followed when it stops.
- **A missing prefab.** A scene whose instance names a prefab that cannot be
  read — deleted, renamed, broken, or nesting one that is — still opens. The
  instance is a placeholder where it stood, entities the scene hung under its
  children wait under the placeholder, and saving writes both back exactly as
  they were. The console names the prefab and says why.
- **Copying and renaming.** Duplicating an instance makes another instance;
  duplicating part of one makes plain entities. Renaming an instance's ID
  renames what is under it.
- **Script fields.** A script's `@export` `Prefab` field, and any component
  field that names a prefab, is picked from the project's prefabs.

### What is deliberately not here

- **Spawned entities are not linked.** `World.spawn` makes entities with no
  `PrefabLink`, as it makes them with no stable ID: nothing spawned is saved,
  and a world saved on purpose after `assign_missing_source_ids` saves what it
  holds rather than references to prefabs it was never placed from.
- **No variants.** See Nested prefabs.

---

Source: `docs/profiles.md`

## Profiles

A profile is reusable authored data that exists independently of scene
entities. It is Sindri's equivalent of a Unity `ScriptableObject`: one asset can
be referenced by many script components, edited once, loaded before gameplay,
and shipped with the project.

Orbital Last Stand is the forcing-function game for this capability. Its module
catalog uses one profile for 160 definitions and their generic effects, so a new
module is a data entry rather than another branch in the chooser or module
script.

### File format

Profiles use the `.profile` suffix and carry their own format version:

```json
{
  "format_version": 1,
  "name": "Module Catalog",
  "type": "module_catalog",
  "values": {
    "modules": [
      {
        "id": 0,
        "name": "Hot Core",
        "weight": 2.0,
        "enabled": true
      }
    ]
  }
}
```

`name` is an author-facing label. `type` is an optional game-defined category;
ordinary game-defined types do not acquire a schema or behavior from it.
Engine-owned `physics_material` and `render_material` types are checked by their
scene subsystems; see
render material profiles (in `09-3d.md`).
`values` is a JSON object whose values may be numbers, text, flags, groups, or lists. The
payload stays game-owned so enemy tuning, item definitions, dialogue, and module
catalogs do not require new engine components.

An unsupported format version or malformed JSON is rejected while the asset is
loaded. The runtime never guesses at a newer document.

### Editor authoring

The project browser recognizes profile files and can create one from a row's
context menu. Selecting a profile opens a structured inspector rather than a
raw text preview. It edits the profile name and type plus nested values, lists,
and groups; list entries can be appended, duplicated, or removed.

A Decay field declared as `@export let catalog: Profile` is drawn with a profile
asset picker. The picker stores the logical asset ID, not an operating-system
path, and marks a missing reference in the same way as other asset fields.

### Decay access

`Profile` is opaque in Decay. A script receives one through an exported field
and reads it through typed `Profiles.*` functions. Every read has an explicit
fallback, so missing optional data is intentional and the result type remains
known to the analyzer.

```rust
script UpgradeChooser {
    @export let catalog: Profile;

    fn show_first() {
        let count = Profiles.count(this.catalog, "modules");
        if count > 0.0 {
            let name = Profiles.text_at(
                this.catalog, "modules", 0.0, "name", "Unknown"
            );
            let weight = Profiles.number_at(
                this.catalog, "modules", 0.0, "weight", 1.0
            );
        }
    }
}
```

Scalar reads are `number`, `text`, and `flag`. `count` returns the length of a
top-level list. The corresponding `number_at`, `text_at`, and `flag_at` calls
read a named field from one object in that list. `name` and `kind` return the
document's author label and `type` respectively.

Profiles are read-only during a run. Per-run state belongs in script fields,
the shared game board, or saves rather than in project assets.

### Loading and export

The declared `Profile` type is also the dependency edge. Hosts inspect compiled
script exports, load referenced profiles through the ordinary asset pipeline,
and pass them into each script frame. Static export records them as the
`profile` asset kind, and native and browser hosts decode the same document.

That restriction is deliberate: a string that merely looks like a profile path
would be invisible to the editor picker and export gatherer, and would fail only
when a frame happened to use it.

Render-material component references are also explicit dependency edges: they
load and ship without a Decay export or an `[assets] include` list. The project
browser can create a render material starter, and its structured inspector
validates factor and sidedness edits before Save. Render profiles remain
read-only during gameplay and do not add a Decay rendering API.

---

Source: `docs/asset-foundation.md`

## Asset foundation

Sindri's asset foundation separates stable authored identity from platform-specific location and runtime ownership.

### Logical identity

`AssetId` is a validated, serializable, relative identifier such as `textures/player.png`. It is not a filesystem path or URL. The asset-source layer resolves the same ID against editor/native files, in-memory content, or an HTTP origin.

IDs use `/` separators and reject absolute paths, empty or dot segments, backslashes, control characters, query strings, and fragments. Validation happens during construction and deserialization so invalid identifiers cannot enter scene or project data unnoticed.

### Typed runtime handles

`AssetStore<T>` returns `AssetHandle<T>`, preventing a texture handle from being used with an audio or mesh store. Repeated requests for the same live logical ID share one generation and one load operation.

Strong handles own liveness, not the loaded value directly. `WeakAssetHandle<T>` can refer to an asset without keeping it alive and can be upgraded only while a strong handle from the same generation exists. Expired weak handles are never revived by a later request.

The store intentionally releases nothing during `Drop`. A runtime calls `collect_unused` at a deliberate maintenance point, making destruction timing observable and testable instead of dependent on whichever thread drops the final handle.

### State and error model

Each entry follows a checked state machine:

```text
queued -> loading -> ready
                  -> failed -> queued (retry)
```

Invalid transitions return `AssetStoreError`. Loading failures retain the logical asset ID, a structured `AssetLoadErrorKind`, and a source-provided diagnostic message. `sindri-core` remains executor-independent; the separate asset queue drives source work and returns completions for the runtime to apply to the store.

### Asset sources

The separate `sindri-assets` crate resolves an `AssetId` to undecoded `AssetBytes` through one object-safe asynchronous `AssetSource` contract. Sources report structured, ID-aware errors that convert into the core load-error model.

The initial implementations are:

- `MemoryAssetSource` for deterministic tests, generated content, and editor tooling
- `FileSystemAssetSource` on native targets, with canonical-path checks that prevent symlinks from escaping the configured root
- `FetchAssetSource` on WebAssembly, using the browser Fetch API and retaining HTTP status and content-type information

#### Where a source looks

The same `AssetId` must find the same file whether it is read from disk or fetched over HTTP, so
both sources resolve it against a configured root and neither lets an ID reach outside one.

`AssetId` does most of the work up front: an ID is relative, uses `/` separators, and cannot contain
`\`, `:`, `?`, `#`, control characters, or a `.` or `..` segment. There is no ID that escapes a root
by construction.

On native targets `FileSystemAssetSource` joins the ID to its root and canonicalises the result,
rejecting anything that lands outside — which also catches symlinks pointing away from the root, a
case path arithmetic alone would miss.

On the web `UrlRoot` builds the URL. A base may be empty (assets sit beside the page), relative
(`assets/`), root-relative (`/games/demo/`), or absolute (`https://cdn.example.com/v2/`), and is
normalised to end in a single slash. Bases carrying a query string or fragment are refused: the
asset path is appended to the base, so anything after it would land in the middle of the URL and
quietly request the wrong thing. Each path segment is percent-encoded to RFC 3986's unreserved set,
so an ID containing spaces or non-ASCII resolves to a URL that requests the file it names, while the
separators between segments stay separators.

Resolution lives in `sindri-assets` rather than inside the browser source, so it is tested on every
target. Rules compiled only for `wasm32` are rules nothing exercises.

Filesystem reads are intentionally not hidden behind a fake executor. They complete when their future is polled, so the native load queue creates and polls them entirely on bounded I/O workers rather than the frame thread. Browser fetches remain genuinely asynchronous.

### Asynchronous load queue

`AssetLoadQueue` accepts bounded `AssetLoadRequest` values and produces `AssetLoadCompletion` values. Every request contains both the logical ID and the originating typed handle's generation. A runtime checks that token against its retained handle before applying a completion, preventing a slow result for an expired asset from overwriting a replacement generation.

On native targets, a fixed worker pool owns the complete source future lifecycle. This keeps synchronous filesystem work off the frame thread. On WebAssembly, the queue holds local futures and polls them without blocking whenever the runtime drains completions; the browser Fetch API supplies actual asynchronous progress between those polls. Neither path requires an executor in `sindri-core`.

Queue capacity includes waiting, active, and completed-but-undrained requests. Enqueueing never waits: duplicate requests and backpressure are returned as explicit errors. The intended runtime flow is:

1. Create a typed handle with `AssetStore::request`.
2. Build and enqueue `AssetLoadRequest::new(&handle)`.
3. Mark the store entry as loading only after enqueue succeeds.
4. Drain completions at a deliberate update point.
5. Verify `completion.request().matches(&handle)` before completing or failing the store entry.

### Typed decoding

`ModelAssetDecoder` decodes static self-contained GLB models while retaining
their hierarchy, mesh reuse, materials and embedded base-color images. The
public facade prepares reusable renderer resources for scene bindings and
native/browser project hosts. See imported models (in `09-3d.md`)
for the exact subset, diagnostics and fixture tests.

Source completions remain encoded bytes until a runtime selects an `AssetDecoder`. `TextureAssetDecoder` accepts PNG and JPEG data and produces dimensions plus tightly packed RGBA8 pixels suitable for the existing `Texture2D::from_rgba8` GPU upload path. `SceneAssetDecoder` deserializes a `SceneDocument` and runs the normal version, stable-ID, parent, and hierarchy validation before the scene can become ready. `SpriteSheetAssetDecoder` does the same for the sidecar that slices a texture into named sprites — its own decoder rather than text a caller parses, because a malformed sheet should fail as the asset it is, naming itself, rather than arriving as a string that fails somewhere later with no idea where it came from. `FontAssetDecoder` validates OpenType bytes and records the first face's declared family while retaining the original bytes, giving native and browser renderers one project-owned face instead of a platform font lookup.

`decode_completion` preserves the request token and turns both source and decode failures into the common `AssetLoadError` model. Its `DecodedAssetCompletion::apply` method checks the retained handle generation before mutating an `AssetStore`, then drives the entry from loading to either ready or failed. A late completion for an expired generation returns a stale-completion error without touching the replacement entry.

### Assets that describe other assets

A sprite sheet is an asset *about* a texture, and its ID says so without anybody declaring it:
`textures/tiles.png` is sliced by `textures/tiles.sheet`. Derived rather than declared, because
a scene naming its sheets would be a place the pairing could be got wrong. `sheet_id_for` is that one
rule, and both the editor looking on disk and a game shipping embedded bytes go through it.

A sheet is only requested when something references a part of the texture it slices — a reference to
a whole image needs no sheet. That is what keeps a *missing* sheet an error worth reporting rather
than the ordinary case for every unsliced texture in a project. The one exception is an animated
sprite, whose own reference names no part because its clips do; `referenced_sheets` knows about that
case explicitly, because nothing in the sprite's reference reveals it.

#### Where a sprite meets the ground

A sheet also says where its sprites *touch the world*:

```json
"anchor": "center"          // the default: the middle of the frame
"anchor": "bottom"          // the middle of the frame's bottom edge
"anchor": [0.5, 0.8438]     // an exact point, as fractions from the top-left
```

A world sprite is drawn on a quad centred on its entity, so without an anchor the middle of the
picture is what lands on the tile. That is right for something that floats and wrong for anything
that stands: a character drawn in the middle of its frame is drawn half a body low, standing in the
tile in front of its own, and nothing fails.

It belongs to the sheet for the same reason the slice does — it is a fact about the picture, not
about whoever draws it, so it is said once beside the image rather than by every scene that uses it.
Fractions rather than pixels so a sheet needs no image size to be understood and the anchor survives
the art being redrawn at another resolution.

`"center"` is the default because it is what a quad already did, so every sheet written before the
field keeps its picture exactly. Baked art declares it anyway rather than leaning on that default: a
sheet that says nothing cannot be told apart from one whose author never considered the question.
Anchors apply to world sprites only — a UI image is placed by its own anchor against the viewport and
never stands on anything.

### Driving all of it

Everything above was in place for a release and had no caller. Before `AssetLoader` landed, the demo's badge was `include_bytes!`, the editor bound two textures a demo crate handed it, and the only stage with a user was the decoder. The reason is visible in the shape: loading one asset correctly is six steps in a particular order — request a handle, enqueue it, move the entry to loading, drain, decode, apply against the handle that is still current — and each one fails quietly rather than loudly when it is skipped.

`AssetLoader<D>` is those six steps written once. It owns a store, a queue, and a decoder; `request` is idempotent, so a scene naming one texture from twenty entities costs one load; `poll` drains, decodes, applies, and reports each asset as ready or failed exactly once. A completion whose handle generation has been superseded is dropped in silence, because the replacement is still coming. A failure is an answer: asking again does not retry, and `retry` says so explicitly.

One trap it hides is worth naming, because it is the kind that would be found late. Taking a fresh handle for an asset that failed does not reset the entry behind it, so a naive retry moves a `Failed` entry straight to `Loading` and is refused. The loader puts it back to queued first.

The loader also owns liveness. It holds a strong handle per asset, which is what keeps store entries alive at all, and `retain` is how a host narrows that to what it still wants — returning the IDs that were released, so whatever was built on top of them can go too.

### Reloading

`AssetWatch` notices that the file behind an asset has changed, and `AssetLoader::reload` loads it again. Together they are hot reload for native development, which is the point at which an editor stops being a thing you restart to see a texture you just saved.

It polls modification times rather than subscribing to filesystem events. A watcher crate would be more efficient and would bring a background thread, a platform-specific event model, and a set of coalescing rules to get wrong; the set of files being watched is a scene's assets, which is tens, and stating tens of paths once a second costs nothing measurable. Both the modification time and the length are read, because either alone misses edits — a filesystem recording whole seconds cannot separate two saves within one, and a rewrite of the same length leaves the length saying nothing. A same-second edit that preserves the length exactly is missed, which is stated rather than pretended away.

Existing and not existing are both states, so an asset appearing where one was missing is a change: a load that failed becomes a working one without a restart. Reporting a change updates what is remembered, so one change is reported once; a caller that declines to act is not told again, which is better than a watcher that repeats itself forever.

`reload` differs from `retry` in what it starts from and what it means: one is "that did not work", the other is "that worked and is now stale". An asset already in flight is left alone, because the read under way may be picking up the new bytes anyway.

Native only. A browser has no modification time to read, and no editor to reload into.

### Decoding the same way everywhere

The promise this layer exists for is that one scene file works from disk and from static web hosting. That has to hold for the bytes as well as the paths: a texture that decodes natively and not in the browser build, or decodes to something slightly different, turns "the same scene" into a claim nobody checked — and it would be found by someone looking at a picture rather than by a test.

`crates/sindri-assets/tests/decode_compatibility.rs` runs one body on both targets: `#[test]` natively, `#[wasm_bindgen_test]` under `wasm32-unknown-unknown`, where wasm-bindgen's runner executes it in Node. The corpus is embedded rather than read from disk, because what is under test is the decoder and not the source.

It is deliberately awkward — every colour type PNG defines, sixteen bits per channel, an interlaced encoding, and a JPEG — because those are the paths where a decoder's feature set can differ between builds. Each image is two by two, small enough that every expected pixel is written down: an encoding with no alpha channel has to arrive opaque, a palette has to be resolved to colours, sixteen bits have to narrow to eight, and an interlaced encoding has to produce the same pixels as the progressive encoding of the same image. The JPEG is checked with a tolerance, since it is lossy and a decoder producing the wrong picture would miss by far more than a rounding step. Bytes that are not an image have to be an error naming the asset on both targets, because a decoder that panicked would take a browser tab down with it.

Running it needs `wasm-bindgen-cli` at the version the workspace resolves; `.cargo/config.toml` names the runner and the install command.

### What a project ships

A scene names `textures/badge.png`. On a developer's machine that resolves to a file, and a wrong one fails loudly. On static web hosting it resolves to a URL, and the ways it can be wrong are quieter: a truncated response, a stale CDN entry, a deploy that replaced half the files. The bytes arrive, they decode, and the picture is last week's.

`AssetManifest` is the project saying in advance what each asset is — its length and the SHA-256 of its stored bytes, before any decoding. A build knows what to publish without walking a directory at deploy time, and a load can check what arrived against what was promised. It is not a security boundary, since anyone who can replace an asset can replace the manifest beside it, but it is the digest the browser's subresource integrity uses, so the day it feeds a `<link integrity>` the numbers are already right.

The file is versioned like a scene, ordered by asset ID so a diff shows the asset that changed and nothing else, and canonical without needing a canonicaliser: the structure is flat and the assets sit in a sorted map, so pretty-printing is already stable. The hash is written as `sha256:` and sixty-four hex characters — hex rather than base64 because a manifest is read in review, and the bytes base64 would save are not worth squinting at.

An asset the manifest does not mention loads normally. A manifest is a statement about what it lists, not a claim that nothing else exists, which is what keeps it a promise rather than a requirement. `AssetLoader::with_manifest` holds arriving bytes to it, checking the length first because that is free and is what a truncated response fails on. The editor picks up `sindri.manifest` from the directory a scene lives in if there is one, and treats a malformed one as absent rather than fatal — it describes the assets, and refusing to open a scene because a file beside it is broken would be refusing to let anyone fix it.

`examples/cube/assets/sindri.manifest` is committed rather than generated at deploy time, because a manifest built from whatever happened to be on the deploy machine is not a promise about anything. A test regenerates it and compares, so editing an asset without updating the manifest fails there rather than at somebody's browser.

### Deliberate boundaries

GPU upload stays outside. A loader that owned a device could not be tested without one, and the host is the only thing that has one: it reads a ready `TextureAsset` and puts it on the GPU itself. Fallback/error assets, root and URL rules, native hot reload, manifests, and the static exporter's content-hashed asset layout now sit on top of this separation. GPU upload remains host-owned. Keeping storage, source, scheduling, decoding, and upload separate is what lets native and WebAssembly hosts share the same ownership and error semantics.

### Model delivery

`AssetKind::Model` marks self-contained binary GLB assets. Native project capture
uses `FileSystemAssetSource`; the browser host uses manifest-verified fetches.
Both use `AssetLoader<ModelAssetDecoder>` and prepare one shared renderer resource
per logical model through `sindri::model::prepare`. Instances reuse that resource
and GPU residency rather than decoding or uploading each frame. Export carries
the original GLB unchanged. See imported models (in `09-3d.md`).

---

Source: `docs/runtime-environment.md`

## Runtime ambient and background

This is feature proof for Explorer and external Low Tide 3D's planned lighting
track. Lighting Lab (`examples/lighting3d`) exercises it; in-tree game adoption
remains open. `daynight.md` records the full project-clock example.

`this.environment` and `entity.environment` refer to an existing
`sindri.environment` component. They expose `ambient_intensity` as a number,
`ambient_color` as Color and `background` as Color. Both colors support whole
assignments and r/g/b/a channel access. Ambient alpha reads one and must remain
one; background retains authored RGBA. The existing authored color range remains
finite f32, without a new clamp or nonnegative constraint. Ambient intensity is
finite and nonnegative. Fog, shadows and post-processing controls are not exposed
by this API.

Reads resolve omitted component defaults. Writes clone the payload, check the
existing and complete candidate with the scene validator, then replace it only
on success. Non-finite/overflowing numbers, invalid ambient alpha, negative
intensity and invalid preexisting settings report runtime errors and leave the
old payload untouched. Whole colors commit once, preserving atomic rejection.
Channel edits preserve other authored channels; omitted colors materialize their
defaults only when edited. Unrelated fields and omitted settings remain intact.
Integer-authored JSON slots accept fractional writes without rounding.

Access neither creates a component nor selects the world's active environment.
Missing components and stale handles are errors. Writes can address inactive
environment entities; scene extraction still chooses the active environment,
ignores inactive ancestors and rejects multiple active environments. No second
environment store or time-of-day service is introduced.

The shared Session runs scripts before extraction. The next frame derives ambient
lighting and clear color from the updated world through the existing native,
browser and editor paths. No restart, renderer uniform layout change, geometry
upload, new pipeline or resource rebuild is required.

Lighting Lab changes ambient intensity/color and background from blue to warm
during its point/spot phases, then restores the original environment at its ready
goal. Its open/compile/play test checks both goals and intermediate extracted
lighting and frame clear. The phase image comparison checks the changing clear
color as well as the colored geometry. Final lit/off controls retain the same
ambient and background, isolating local-light illumination. This is separate
from fog/post delivery and game adoption. Full transitions and mounted-GLB
sun-shadow proof now have their own feature example in `daynight.md`.


Local validation passes 535 scoped LSP/game/Decay/catalogue tests, warning-denied
workspace Clippy, a workspace WASM check and typed preflight with zero runtime
reminders. Native phase backgrounds change from RGB [33,44,97] to [97,44,33];
excluding the background, surfaces add 111,271 red pixels and remove 154,887 blue
pixels. Final lit/off controls retain 91,624 brightened pixels. Native editor
Play visibly shows both environment phases and the ready goal; Stop/save restores
the original environment payload, all 13 authored entities and their disabled
black lights. Native/editor authored-color verification also passes.

Rebuilt WebGPU Chromium captures show the same RGB [33,44,97] to [97,44,33]
background transition. Excluding the sampled background, the on-control phases
add 109,022 red surface pixels and remove 154,857 blue pixels. Both lit/dark
exports fetch six assets, reach ready and report no runtime/GPU errors; the final
comparison retains 91,020 brightened pixels. The original 300-prop browser
workload and its shadow comparison pass (2,088 darkened pixels). The phase checker
samples inside the background to avoid the browser canvas's CSS border.
