# Native export — `engine export`

Exporting a game as one executable. How a scene or a project becomes one executable a player runs,
what the executable carries, and how to check what it says about itself. The manifest itself is `docs/PROJECT-MANIFEST.md`.

## Contents

1. [Quick start](#quick-start)
2. [Commands](#commands)
3. [What the executable carries](#what-the-executable-carries)
4. [Two exporters, one format](#two-exporters-one-format)
5. [Manifest fields and precedence](#manifest-fields-and-precedence)
6. [Release and development exports](#release-and-development-exports)
7. [Generated icon outputs](#generated-icon-outputs)
8. [Windows version information](#windows-version-information)
9. [The surfaces a player sees](#the-surfaces-a-player-sees)
10. [Running an export](#running-an-export)
11. [Output paths](#output-paths)
12. [The launcher cache](#the-launcher-cache)
13. [Target support and export-time requirements](#target-support-and-export-time-requirements)
    ([Two engine builds](#two-engine-builds), [Signing](#signing), [Linux packages](#linux-packages))
14. [When a change takes effect](#when-a-change-takes-effect)
15. [Errors](#errors)
16. [Checking an export](#checking-an-export)
17. [From the editor](#from-the-editor)
18. [The web target](#the-web-target)
19. [Third-party notices](#third-party-notices)
20. [The shader pack and pre-warm lists](#the-shader-pack-and-pre-warm-lists)
21. [Textures cooked ahead of time](#textures-cooked-ahead-of-time)
22. [Known limits](#known-limits)

## Quick start

A project with a name, a version and an icon (`fixtures/projects/valid` is one):

```sh
engine project validate my-game --release      # the manifest, its icon and its startup scene
engine export my-game --target native          # dist/<job id>/cloudbound(.exe)
dist/job_…/cloudbound --info                   # what the executable says it is
dist/job_…/cloudbound                          # play, in a window
```

`--info` prints one JSON object: the payload id and version, the entry (the scene file, the engine,
whether it has a window), and `branding`, the application record described below.

## Commands

```text
engine export <scene.json | project folder | argus.project.json> --target native|web|macos|ios
              [--out dir] [--development] [--name x] [--telemetry URL] [--physics deploy|critic]
              [--no-build] [--launcher-target triple] [--web-runtime dir] [--zip] [--precompress] [--json]
              [--sign-cmd "<command with {file}>"] [--package tar|appimage|tar,appimage]
```

| Location | Export | Output name | Application record |
|---|---|---|---|
| a project folder or `argus.project.json` | every scene, booting into its startup scene, branded | `application.executableName` | yes |
| any other file (a scene) | that scene, legacy defaults | the scene's `meta.name`, with characters outside `A-Z a-z 0-9 _ -` replaced by `-` | no (`branding: null`) |

| Flag | Meaning |
|---|---|
| `--target native` | one executable for the host (or `--launcher-target`) |
| `--target web` | a scene or a project: a static folder, `<out>/web/` (default `dist/web/`), for any static host ([The web target](#the-web-target)) |
| `--target macos` | a project: `<displayName>.app` in `<out>` (default `dist/macos/`). It is not signed or notarised and needs a Mac runner for that. Built off a Mac the engine is a stand-in, so the layout is right but the app will not run |
| `--target ios` | a project: the Xcode project's inputs in `<out>/<name>-ios/`. It is not an app until a Mac runs `xcodegen` and `xcodebuild` |
| `--web-runtime dir` | web: the built runtime folder (`node scripts/web/build.mjs --out <dir>`); default `$ARGUS_WEB_RUNTIME`, else `target-web/` in a checkout, else the installed engine's `sdk/web-runtime/`. A runtime stamped with another engine version (its `build-report.json`) is refused (`engine-stale`) |
| `--out dir` | the folder the executable goes in; without it, a new `dist/<job id>/` |
| `--development` | validate a project for development: a missing icon uses the built-in one |
| `--name x` | the output file name, overriding the executable name or scene name |
| `--telemetry URL` | a telemetry endpoint baked in; the executable's own `--telemetry` overrides it. The game asks the player first (Yes / No, No the default) and sends nothing without a yes; `ARGUS_TELEMETRY_CONSENT=1` or `0` answers ahead. Only `http://` can be sent, and the question says it travels unencrypted |
| `--physics critic` | embed the workspace's default headless engine instead of the deploy engine |
| `--no-build` | never run cargo; fail when an engine or launcher is missing |
| `--launcher-target` | the Rust target triple of the launcher, e.g. `x86_64-unknown-linux-musl` |
| `--sign-cmd "<command with {file}>"` | run this command to sign the engine and the finished executable ([Signing](#signing)) |
| `--package tar\|appimage` | also write a Linux tarball and/or AppImage beside the executable ([Linux packages](#linux-packages)) |

The location comes first: a flag followed by a word takes that word as its value.

**Prefabs.** A project export carries every `*.prefab.json` of the project at `app/<its
project path>`, where an instance's `prefab.source` and a script's `ctx.world.spawn({ prefab })`
name it, since the runtime takes `app/` as the project root. The files a prefab's assets name are
stored under their hash like the scene's, and the paths inside the prefab are rewritten to the same
`assets/<hash>.<ext>` the scene gets for the same file (a prefab's asset path resolves against the
folder of the scene that uses it, and the startup scene is packed in `app/`); nothing else in the
file changes. The behaviours of the prefabs a scene places are bundled like its own, and those of
every other prefab wherever they resolve, because any script may spawn any prefab. So an export
expands its instances at load and spawns an unplaced prefab as `argus run` does, and as the editor's
Export does. A prefab file the contract refuses travels as written, and the game refuses to spawn it
with the same message.

A **scene-only** export carries no project, so it has no prefabs. It refuses, with `usage` and a
message opening `prefab-export-unsupported:`, a scene that places a prefab instance, and a behaviour
that spawns a prefab (`ctx.world.spawn({ prefab })`) when the scene's project has prefab files:
export the project folder instead.

From TypeScript: `exportNative({ project, development?, outDir?, … })` or
`exportNative({ scenePath, … })` in `@argus/export`, which returns the report the CLI prints
(`NativeExportReport`) and throws `ExportError` with a stable `code` ([Errors](#errors)).

## What the executable carries

The executable is the launcher (`packages/export/launcher`, std-only Rust) with a payload
appended, then a JSON index and a 24-byte trailer. Nothing in it needs Node, esbuild, cargo or the
source checkout.

| Payload path | What |
|---|---|
| `bin/argus` or `bin/argus.exe` | the engine: the deploy physics build with a window and gamepads (`--no-default-features --features deploy,window,gamepad`) |
| `app/scene.json` | a **single-scene** export's scene, asset paths rewritten to `assets/<hash>.<ext>` |
| `app/<project path>.scene.json` | every scene of a project export of several, the startup one too, at the path the project spells and `ctx.session.loadScene` takes, so a game can go back to the scene it started on (a "Quit to Menu"). The startup scene is packed only there, as the editor packs it; the rebased manifest's `startupScene` and the launch entry name it. (It used to be packed at `app/scene.json` as well, every file it named twice: Sprocket Rally 34.3 MB, now 21.5 MB.) |
| `app/assets/<hash>.<ext>`, `app/<scene folder>/assets/<hash>.<ext>` | each asset file named by its content hash, beside the scene that names it: `app/assets/` for a scene packed directly in `app/`, `app/scenes/assets/` for `scenes/*.scene.json`. The runtime resolves a scene's (and the prefabs' it spawns) assets against the scene's own folder, so scenes in two folders each carry their own copy |
| `app/<folder>/assets/<hash>.<ext>` | `engine export`: for a scene packed below `app/` (`app/<folder>/x.scene.json`), its hashed files and every prefab's again, since the runtime resolves a scene's asset paths, and those of the prefabs it spawns, against the scene's own folder |
| `app/<project path>.prefab.json` | project exports: every prefab file, its asset paths rewritten as the scene's (`engine export`; the editor packs it as authored, beside its assets at their project paths) |
| `app/<project path>` of a referenced file | `engine export`: every file a component names in a field the contract marks `fileRef` (a road's `.road.json`, a terrain's `.terrain.json`, an instanced mesh's `.instances`), in every packed scene and prefab, and every file those name in turn ([Referenced files](#referenced-files)) |
| `gameplay/entries.json`, `gameplay/modules/*.js` | the behaviors of **every** packed scene bundled to JavaScript, and the built-in modules they name |
| `app/argus.project.json` | project exports: the manifest in canonical form, with `startupScene` rebased to the packed startup scene's path and `application.icon` to `icon.png` |
| `app/icon.png` | project exports: the icon file exactly as authored (or the built-in icon) |
| `app/branding.json` | project exports: the application record |
| `app/ui/**` | project exports: the whole `ui/` tree as authored — screens, styles, theme, locales, components, icons, scenarios |
| `app/ui/screens.json` | project exports: every screen already described, so a machine with no esbuild can register them |
| `app/ui/fonts/*.ttf` | project exports: a font, so a game that draws text can start; the project's own when it ships one |
| `app/ui/fonts/fallback/*.ttf`, `app/ui/fonts/fonts.json` | a project with no fonts of its own whose locales the default cannot draw: the engine's fallback faces those locales need, and the scripts each answers for |
| `app/data/**` | project exports: the whole `data/` tree as authored, which behaviours read with `ctx.files` (docs/API.md); nothing at all for a project with no `data/`, so its payload and id are what they were |
| `THIRD-PARTY-NOTICES.txt` | the licences of the third-party code and fonts in the engine, the launcher and what an export may pack ([Third-party notices](#third-party-notices)) |

For a project, `app/` is itself a valid project: the exporter writes it to a scratch folder and
validates it before packing.

### Referenced files

A component can name a file the engine reads at load: a road's `source` (`.road.json`), a terrain's
`source` (`.terrain.json`), an instanced mesh's `source` (`.instances`, which is where a foliage
layer's plants live). The contract marks each such field with a `fileRef` keyword
(`packages/contract/src/file-ref.ts`; it appears in `schema.json` beside the field's description),
and `engine export` packs every marked file of every component of every packed scene and prefab at
`app/<path as written>`. It looks for the file as the engine does, under the project root and then
the scene's own folder, and the game finds the packed copy from either, since its `app/` is the
project root. A packed file the contract has a schema for is read for its own marked fields, and so
on (`FILE_REF_DOCUMENTS` in `file-refs.ts`): a terrain file's layer materials (`layers[].material`,
unless it is a scene asset id) and a `stamp`'s height `image`, project-relative; a material file's
textures and layer materials, relative to the material file, packed beside it. A value with a
scheme (`builtin:`, `procedural:`) names no file.

A marked file that does not exist refuses the export with `usage`, naming the scene or prefab, the
entity, the component, the field and the path, and for a file reached through another, the chain:
`scenes/race.scene.json: the terrain source of entity "ground" > terrain/race.terrain.json
layers/0/material "materials/dust.material.json" does not exist: ...`. The game would otherwise
start without it and say nothing in the export's report.

Not packed: a `.foliage.json` recipe, which the editor cooks into meshes and `.instances` files and
the engine never reads (it is followed, with its `bark`/`leaf` texture paths, if a marked field ever
names one); a terrain's `sculpt`/`paint` delta tiles (`<name>.terrain/*.tile`), which no runtime
path reads yet; and a material's `shader`, which reaches the game through the shader pack.

### File names

Every packed file keeps its project path, and a game is read on Windows, so an export refuses names
Windows cannot hold, whatever system it runs on. The refusal is `usage`, naming the path:

- **Two paths that differ only in case** (`Logo.png` and `logo.png`, `Art/` and `art/`): Windows sees one
  file. `payload path given twice: ... (paths that differ only in case are one file on Windows)`. Rename
  one of them.
- **A name Windows cannot use:** one with `<`, `>`, `"`, `|`, `?`, `*` or a control character; one that
  ends in a dot or a space (Windows drops it); or a device name, `CON`, `PRN`, `AUX`, `NUL`, `COM0`-`COM9`
  or `LPT0`-`LPT9`, with or without an extension (`aux.txt`, `nul.png`). `payload path not allowed: ...`,
  with the reason. Rename the file or folder.
- **A reference whose case differs from the file** (a scene names `Textures/Grass.png`, the file is
  `textures/grass.png`) is refused before that, on every system, as `project-path-missing`, naming the file
  that is there. Change the reference to match the file exactly.

### The user interface

The launcher runs `app/` as a project root, so the runtime finds stylesheets, the theme,
`ui/components.json`, the locales and every `ui/icons/**` image exactly where `argus run
<project>` finds them. There is no export-only path on the runtime side.

Screens are the exception, because registering one means **compiling** one: the describer bundles
a `.tsx` with esbuild and evaluates it, and a player's machine has no esbuild, no `node_modules`
and no `@argus/ui`. So describing is a build step. Both exporters describe every
`ui/screens/**` file and write the answers to `app/ui/screens.json`, which
`argus::DescribedScreens::from_file` reads at load; the registration that follows is the same one
`argus run` performs. The `.tsx` sources travel too, so a shipped build is inspectable, but the
descriptions are what registers — they are the bytes a check has seen, which is the rule
`LoadOpts::compiled` already keeps for behaviours.

Each screen also ships the bundle it was described from, so a push re-renders it at its props.
Every bundle carries its own copy of `@argus/ui`, so the file keeps each esbuild module once
(`chunks`) and each bundle as the indices of its pieces (`bundles`), written without indentation
(`argus::pack_bundles`, `packBundles`). Joined, a bundle is byte for byte what esbuild wrote, so
each screen still runs its own copy; a bundle written as one string, as older exports did, still
loads.

A screen that will not compile does not stop an export. `docs/UI.md` §8's rule outlives the build:
the reason is packed beside the descriptions and the load reports it by file, so a game with one
screen missing says which one rather than failing mysteriously.

**A font.** A scene that declares text and finds none refuses to load, which is the right call —
every label silently dropped is worse — but a player has neither `--font`, nor `$ARGUS_FONT`, nor
a checkout to walk up into for the repository's own fixture. A project export packs one under
`ui/fonts/`, which `argus::project_font` looks in after the two flags and before the walk-up. A
project that ships its own font there keeps it and nothing is added.

**The fallback faces its languages need, and no others.** On a player's machine the packed fonts
are the whole chain, and an export is refused if a shipped locale has a character none of them
maps (`check_locale_glyphs` in the editor, `localeGlyphGaps` in `packages/export`). So for a project
that ships no fonts, the export also packs any of the engine's fallback faces
(`argus::EXPORT_FALLBACK_FONTS`: Noto Sans SC, Arabic, Hebrew and Symbols 2 subsets, then DejaVu Sans
Mono for Greek, Cyrillic and other symbols) that draw a
character the fonts packed so far cannot. It tries them in the engine's own order and packs each
one only if it helps. They go under `ui/fonts/fallback/`, named with their scripts in
`ui/fonts/fonts.json`. The folder is there because the primary is the first `.ttf` in name order,
and `ArgusNotoSansArabic.ttf` sorts ahead of `Inter-Regular.ttf`, the default. An English-only game
carries the default font alone (with `OFL-Inter.txt` beside it: each packed engine font's licence
text travels with it, `argus::ENGINE_FONT_LICENCES`); the Collection course (en, fr, ar) adds the
Noto Sans Arabic subset under `fallback/`, because Inter's subset has no Arabic. The check still runs over exactly what was packed. The fallbacks are subsets cut
to the repository's own strings, so a real project's character outside them is still refused, with
the locale and the character named.

**Where those fonts come from.** The editor finds `fixtures/render/fonts/` (`engine_fonts_dir` in
`crates/editor/src/editor.rs`) in this order:

1. walking up from its executable;
2. `$ARGUS_REPO`;
3. the installed SDK: `$ARGUS_SDK`, else `<exe dir>/sdk` or `<exe dir>/../sdk`. `scripts/sdk/pack.mjs`
   ships the fonts there;
4. the source tree it was compiled from.

`$ARGUS_FONT` names the default font and outranks all four. The CLI bundle carries the same files
(`packages/cli/bundle.mjs`). The walk used to end the whole search when it ran out of parents, so
an executable outside the tree, such as the exit gate's `argus-agent`, exported a game with no font
at all.

## Scenes

**Every scene of the project, booting into the manifest's `startupScene`.**

A project's scenes are its `*.scene.json` files, and a project can have many. An export carries all
of them, each at the path the project spells — `app/scenes/credits.scene.json` for
`scenes/credits.scene.json` — which is the path `ctx.session.loadScene` takes, so a game that
switches scenes finds the file it names. The assets and the behaviours are the **union**: one copy
of a model two scenes share, and one `gameplay/entries.json` covering every behaviour any of them
runs.

**Which scene boots is still the manifest's decision**, and still the only thing File ▸ Set as
startup scene changes. The Export panel names every scene it packs and which one boots, and says so
plainly when the open scene is not the one that boots — reading the manifest from disk as it opens
(`Editor::adopt_manifest_if_changed`), so a `startupScene` changed by the workspace service, by
another window or by a `git pull` is what the panel names, rather than what the editor resolved when
the project was opened.

**A single-scene export composes exactly as it did before.** `scenes` is optional in the payload
entry and is left out entirely when there is one scene, so the canonical JSON `index.id` hashes is
byte for byte the one a earlier exporter wrote and every existing one-scene export keeps its
identity. `crates/export/tests/payload.rs::a_single_scene_entry_serialises_as_it_did_before_scenes`
pins that string by hand.

**What is refused.** Two scenes that map one behaviour module *name* to different files: the
payload has one slot per module name, so packing one of them would ship a scene running the other
scene's behaviour. The editor refuses it with both scenes named; `crates/export` refuses it again
for every other caller. A scene of the revision that does not resolve — a missing asset, a missing
compiled module, a document the contract refuses — stops the export rather than shipping an
executable carrying a scene the player can reach and the engine cannot load.

**And Play refuses the same scene, in the same words.** The verdict is reached once, by
`LoadDescriptor::resolve`, which walks every scene of the revision and records `scene-module-conflict`,
`content-incomplete`, `project-invalid` or `modules-missing` on the ones it cannot resolve. The
export reads that record and refuses to compose. The *runtime* is handed the same record, in the
same map that carries the compiled behaviours, and `ctx.session.loadScene` refuses those scenes by
name with a `scene-load-failed` whose `reason` is the code above (docs/API.md). So a revision is
never playable in one place and unshippable in another without saying so: the scene the export will
not carry is the scene Play will not enter, and the author is told which scene and why the first
time a behaviour asks for it, rather than the first time someone exports.

A scene with a problem does **not** stop the rest of the revision. Play of a good scene is never
refused because a scene nobody opened has a missing asset, and a build with one broken scene is a
build someone is in the middle of fixing. Only the broken scene is unreachable.

**Reaching them.** `ctx.session.loadScene("scenes/credits.scene.json")` (docs/API.md) switches at
the end of the tick that asks. It resolves against the project root the launcher unpacked, so a
running game reads exactly the files it shipped with and can reach nothing outside them.

**Identity.** The payload id is a SHA-256 over every file's path and hash, then the entry's
canonical JSON. A change to the version, the icon, the manifest, the scene or the engine is a new
id. The launcher unpacks by id, so a new export never runs an old unpacked copy.

**Provenance.** An export made from a workspace revision also records `revisionId`, `artifactId`
and `buildId`, in the payload entry and in the application record. Because the id already hashes
the entry, those three enter it with no new mechanism: two exports of byte-identical content from
different revisions have different ids, and `--info` reads the ids back out of the finished file.
All three are optional in the format. A launcher that predates them ignores them, and an export
that does not set them hashes exactly as it did before, which the pinned id in
`crates/export/tests/payload.rs` and `packages/export/test/rust-parity.test.ts` holds in place.

The payload entry, in the order it is written: `scene`, `sceneFile`, `engine`, `physics`,
`window`, `branding`, `projectDir`, `telemetry`, `revisionId`, `artifactId`, `buildId`, `scenes`.
The order is part of the format, not a detail: the index is `JSON.stringify`d, so its bytes carry
it. `scenes` is a list of `{ scene, sceneFile }` in payload path order, present only when the
export packs more than one scene, and `--info` prints it.

**The application record** (`app/branding.json`, `--info`'s `branding`):

| Field | From |
|---|---|
| `projectId`, `applicationId`, `displayName`, `executableName`, `version`, `buildNumber` | the manifest (`buildNumber` defaults to 0) |
| `prerelease` | `version` has a prerelease part |
| `purpose` | `release` or `development` |
| `manifestHash` | `projectManifestHash` of the manifest as authored |
| `icon` | `source` (`project` or `builtin`), `path`, `sha256`, `width`, `height` |
| `window` | width, height and resizable, defaults applied |
| `windows` | Windows targets: the version numbers and strings written, the icon sizes, the `.res` hash; `null` elsewhere |
| `surfaces` | what each [surface](#the-surfaces-a-player-sees) shows, and why |
| `revisionId`, `artifactId`, `buildId` | the revision the export was composed from; absent when the caller did not know them |

## Two exporters, one format

There are two implementations of everything above, and a player's launcher reads whatever it is
given, so they have to agree byte for byte.

| | `packages/export` (TypeScript) | `crates/export` (Rust) |
|---|---|---|
| Runs | the CLI, CI, a developer's machine | the installed editor, and `argus-export` |
| Builds the engine and launcher | with cargo, unless both are given | never; both are inputs |
| Transpiles behaviours | esbuild, in process | never; already-JavaScript modules are inputs |
| Windows icon and version | compiled to a `.res` and **linked** at build time (needs MSVC) | **patched** into a prebuilt template's resource slot (needs nothing) |
| Can be cancelled | no | yes, between stages and inside the asset loop |

Numbers are part of that agreement: the TypeScript side merges a caller's record with `JSON.parse`
and `JSON.stringify`, so `crates/export`'s hand-written JSON re-spells every number the way
`Number::toString` does — `1` for `1.0`, `1e+21` for `1e21`, `0` for `-0`, `null` for `1e999` — and
refuses what the grammar has no spelling for (`-00`, `-01`) and anything past 256 levels of nesting,
rather than writing an `app/branding.json` no reader of the payload can parse. A release export is
refused unless an icon actually reaches the executable: on Windows that is the resource slot, so
`resources` has to carry one, and elsewhere it is `app/icon.png`, whose bytes have to decode.

`crates/export/tests/parity.rs` composes a real export in Rust, hands the identical files, entry
and timestamp to `payload.ts` through node, and fails unless the two executables are identical and
the two ids are equal. It skips when node is absent, and
`packages/export/test/rust-parity.test.ts` checks the same agreement from the other side. The
error codes are one list in two files, compared by both tests.

**No *cargo* on the TypeScript path either — but it is not the toolchain-free export.** Giving
both binaries — the `engine` option of `exportNative` or `ARGUS_DEPLOY_BIN`, and its `launcher` option or `ARGUS_LAUNCHER_BIN` — takes cargo
and rustc out of `engine export`: nothing is built, and the launcher target comes from the running
process (`platformTriple`) instead of from `rustc -vV`. A branded *Windows* export still cannot
take a prebuilt launcher on this path, because its resources are a link-time input; that is what
the Rust exporter's resource slot is for.

What it does **not** take out is Node, esbuild and the checkout. `engine export` bundles every
local behaviour with `esbuild.build` in process, finds the built-in gameplay modules through
`findGameplayDist()`, and reads the built-in icon from `brand/argus-icon.png` under `repoRoot()`.
All three are there whatever the two binaries are set to. So the toolchain-free export — the one a
machine with nothing on it can run — is `crates/export`, which the editor calls in process and
which `argus-export` is the command-line form of; `engine export` is the developer's and CI's
exporter and it needs a checkout to run in.

`ARGUS_EXPORT_NO_TOOLCHAIN=1` (or `noToolchain` on `exportNative`) says that out loud: with it,
any attempt to spawn cargo or rustc is refused by name rather than run. `ARGUS_DEPLOY_BIN=""` and
`ARGUS_LAUNCHER_BIN=""` are refusals too — set and empty is a mistake, and reading it as "not set"
is what quietly put cargo back into an export that was meant to have none.

**The resource slot.** A launcher built on Windows without `ARGUS_LAUNCHER_RESOURCES` ships a
placeholder resource of fixed size at every id an export needs: one RT_ICON per
[icon size](#generated-icon-outputs) sized as that size's uncompressed DIB, an RT_GROUP_ICON, and
an 8,192-byte RT_VERSION. That is 321,574 bytes of slot, whatever icon an export ends up with.
`patch_resources` overwrites the data in place — no section moves, no RVA changes, no change in
file length, only the directory's `Size` fields rewritten — so branding needs no linker. A blob
that will not fit its slot is refused with `resource-slot` and the template's generic icon is kept
with a visible note, rather than a truncated icon being shipped. A template with **no** slot at all
is a different thing and is refused outright: it was built for a target whose `build.rs` writes no
placeholder resources (anything but a Windows MSVC target, which now says so with a
`cargo:warning` as it builds), so there is nothing to patch and an unbranded executable would ship
in silence. The sizes are stated in two
places (`packages/export/launcher/build.rs`, which has no dependencies, and
`crates/export/src/winres.rs`) and a test compares them.

**`argus-export`.** The same composition without the editor, for the Windows gate and for
reproducing what the panel does:

```
argus-export --engine argus.exe --launcher argus-launcher.exe \
  --launcher-sha256 @argus-launcher.exe.sha256 --engine-sha256 @argus.exe.sha256 \
  --manifest app/argus.project.json --scene scene.json --assets-dir assets \
  --modules-dir modules --icon icon.png --version 1.2.3.4 --display-name "My Game" \
  --revision rev_… --artifact art_… --build bld_… --out dist/MyGame.exe
argus-export --info dist/MyGame.exe
```

`--assets-dir` packs a folder's contents under `app/`, keeping their relative paths, so a folder
holding `assets/ab12.png` gives `app/assets/ab12.png` — the paths the scene was rewritten to point
at. `--modules-dir` and `--builtins-dir` take flat folders of `.js` files named by their module
name. Nothing here rewrites a scene or transpiles anything: the caller hands over files that are
already what the payload should hold.

`--launcher-sha256` and `--engine-sha256` take the hash itself or `@<path>` to the `.sha256` file
the installer wrote beside the binary; either way one parser reads it (`sha256sum` form, bare hex,
a byte-order mark, CRLF, either case), the same one the editor and `verify_template` use. Both
binaries are verified by their bytes before anything is composed. On the editor's path neither is
optional: an install with no readable record of a hash is refused, with the installer flag that
writes one, rather than composing an unverified launcher or packing an unverified engine.

On failure it prints `argus-export: <code>: <message>` and exits 1, with the same codes as
[Errors](#errors).

**Where the two exporters still differ, byte for byte.** `app/branding.json` and
`gameplay/entries.json` are written by one shape and one layout on both sides now, and
`crates/export/tests/parity.rs` composes both through `branding.ts` and `native.ts` and compares
the bytes. One field does not agree and cannot: `windows.resourcesSha256` hashes the compiled
`.res`, whose 256 px icon is a PNG, and a PNG's pixels are a deflate stream — `packages/export`
deflates with `node:zlib` and `crates/export` with the `png` crate. Every other resource byte is
identical, image by image, which `the_windows_resources_agree_with_winres_ts_apart_from_the_256_px_png`
pins. It matters only for a branded Windows export composed by both exporters for the same
revision, which is not a thing either of them does today: `engine export` refuses a prebuilt
launcher for a branded Windows export, and the editor never links resources.

## Manifest fields and precedence

| Setting | 1. Export option | 2. Project | 3. Legacy default (scene export) |
|---|---|---|---|
| Output file name | `--name` | `application.executableName` | scene `meta.name` |
| Scene the game boots into | — | `startupScene` | the scene given |
| Scenes packed | — | every `*.scene.json` in the project | the scene given, alone |
| Executable icon (Windows) | — | `application.icon`; development export without one: built-in | none |
| Version resource (Windows) | — | `application.version`, `application.buildNumber` | none |
| Window size | — | `window.width` × `window.height`, passed as `--window-size` | the engine's 1280 × 720 |
| Window title | — | not applied yet: `Talos — scene.json` | `Talos — scene.json` |
| Window icon | — | not applied yet | none |
| Resizable | — | not applied yet | the engine's default |

Window title, window icon and resizable come from the engine at run time. Until project launch
(S3) runs the bundled project, the embedded engine runs the entry's scene file (`app/scene.json`, or the startup scene's project path) and titles its window from
the file name. The record's `surfaces.windowTitle` says so.

## Release and development exports

An export is a **release** export by default, and `--development` makes a development one. Two
things differ: the engine it embeds, and how a project is validated.

**The engine.** A release export embeds the release engine,
`--no-default-features --features deploy,window,gamepad` (`ARGUS_DEPLOY_BIN`, or built into
`<target>/export-engine`). It has no query API and no file watcher: no axum, tokio or notify is
linked (`scripts/release/check-release-deps.sh`), it refuses `--serve` with "built without the
`serve` feature", and `--info` on the executable says `"serve": false`. It sends its own telemetry
(`crates/app/src/telemetry.rs`) and is smoke-tested with `--self-test` (`docs/SELF-TEST.md`). A
development export embeds the same build plus `serve,hot-reload` (`ARGUS_DEPLOY_DEV_BIN`, or built
into `<target>/export-engine-dev`); its launcher serves the query API, which is what the bench, the
agent's `export.run` and the export tests drive, and `--info` says `"serve": true`. Which one an
engine is, is read from the binary, and the payload entry says `"serve": true` only for an engine
that serves; the launcher passes `--serve` only then. `--development` applies to a scene export too,
where it changes only the engine.

**Validation.** A project export validates the project for release by default. Every rule in
`docs/PROJECT-MANIFEST.md` applies, and a project without `application.icon` is refused
(`project-icon-required`), so a release never ships the placeholder by accident. `--development`
validates for development. A missing icon then resolves to `builtin:argus-icon`, the built-in icon
`brand/argus-icon.png`; the record says `icon.source: "builtin"`. A project that has an icon uses it
either way.

**Self-test scenarios.** A project's `selftest/*.json` are packed at `app/selftest/`, so
`<executable> --self-test <name>` runs them on the shipped build (`docs/SELF-TEST.md`).

The built-in icon is generated by `scripts/brand/make-icon.mjs` (an original geometric mark, no
third-party art or fonts) and regenerates byte for byte: `node scripts/brand/make-icon.mjs --check`.

## Generated icon outputs

The icon is decoded by the exporter's own PNG decoder on every target, so a PNG that cannot be
decoded is an export error everywhere (`icon-decode`), not only on Windows:

- every colour type and bit depth, `tRNS` transparency and Adam7 interlacing;
- at most 4096 × 4096 pixels, refused before anything is inflated;
- the image data may inflate only to the size its header needs, so a zlib bomb stops there;
- a broken or truncated zlib stream, an unknown row filter and a palette index past the palette are
  refused. (The manifest's own icon check reads structure and CRCs only, so these reach export.)

On Windows the icon becomes one group icon (`RT_GROUP_ICON` 1) of eight images:

| Size (px) | Format | Why |
|---|---|---|
| 16, 24, 32, 48 | 32-bit BGRA DIB with an AND mask | the classic sizes |
| 20, 40, 64 | 32-bit BGRA DIB with an AND mask | 125 %, 250 % and 400 % display scaling |
| 256 | PNG | large icons and Explorer's big views |

Each size is an exact area average of the source over premultiplied alpha, so a transparent
pixel's colour never bleeds into its neighbours. The AND mask marks fully transparent pixels.

## Windows version information

Written as `RT_VERSION` 1 (language 0409, code page 1200):

| Field | Value |
|---|---|
| FILEVERSION, PRODUCTVERSION (numeric) | `MAJOR, MINOR, PATCH, buildNumber` (`windowsVersionInfo`), each re-checked against 16 bits and never truncated |
| File flags | `VS_FF_PRERELEASE` when the version has a prerelease part |
| `FileVersion` | `MAJOR.MINOR.PATCH.buildNumber` |
| `ProductVersion` | `application.version` verbatim, prerelease included |
| `FileDescription`, `ProductName` | `application.displayName` |
| `InternalName` | `application.executableName` |
| `OriginalFilename` | `application.executableName` + `.exe` |
| `Comments` | `<application.id> (project <projectId>)` |
| `CompanyName`, `LegalCopyright` | not written: the manifest has no publisher, and an invented one would be untrue |

`1.0.0-beta.2` and `1.0.0` with the same build number have the same numeric version, and Windows
installers compare the numbers: raise `buildNumber` across a release's prereleases.

## The surfaces a player sees

These are separate, and today they do not all show the application:

| Surface | Windows | Linux | Where it comes from |
|---|---|---|---|
| Output file name | `executableName.exe` | `executableName` | the export |
| Explorer / file manager icon | the executable's group icon | none (no executable icon exists) | the launcher's resources |
| Properties › Details version | the version resource above | none | the launcher's resources |
| Running window title | `Talos — scene.json` | same | the embedded engine (legacy until S3) |
| Running window icon | none set by the engine | none set by the engine | the embedded engine (legacy until S3) |
| Taskbar entry | the engine process's window; that process (`bin/argus.exe`) carries no application resources | the engine's window | the running window's process |

The launcher runs the engine as a child process, so the running window and its taskbar entry belong
to the engine. The title comes from the engine's code (`Talos — <scene file name>`); what the window
icon and taskbar entry look like is not observed by the automated gates, because that needs a
windowed launch.

## Running an export

```text
<executable> [--headless] [--offscreen] [--port N] [--ws-port N] [--scale X] [--telemetry URL]
             [--stuck-after S] [--player ID] [--extract-only] [--print-dir] [--info]
             [--notices] [--self-test SCENARIO]
```

- **Release and development.** Everything below about ports, `argus-ready` and the query API is a
  development export's. A release export's engine has no query API: `--port` and `--ws-port` do
  nothing, there is no `argus-ready` line, the launcher waits for the engine and exits with its
  status, and the engine sends telemetry itself (the launcher passes it the endpoint and its
  session id, and sends `session-end` with `reason: "signal"` only when a signal stops the engine
  first). A release export run `--headless` simulates its opening ticks and exits; there is no
  server to keep it up.
- **`--self-test SCENARIO`** plays a scenario in the engine, offscreen unless `--headless`, and exits
  0 (pass), 1 (fail) or 2 (unreadable); the report is `logs/self-test.json` in the unpack directory.
  Any export, release or development (`docs/SELF-TEST.md`).

- **Windowed by default.** The embedded engine has a window, so the executable opens one unless
  run with `--headless`, which also skips the GPU. An engine built without a window (`--physics
  critic`) renders offscreen without `--headless`, as before. `--offscreen` renders with no window
  whatever the engine has — a first frame at `frame.png` in the unpack directory, and `GET
  /frame.png` and `POST /capture` answer — and wins over `--headless`, so a caller can pass both
  and a launcher from before `--offscreen`, which ignores a flag it does not know, still starts
  headless rather than in a window. It is what the agent's `export.run` passes.
- **Escape, and leaving.** In a window (an export, or `argus run --window`) Escape belongs to the
  game when the game binds it: when an action in the scene's own `meta.input`, or in a player's
  saved override, has `Escape` among its keys (a pause key, a menu's back). It then reaches the
  game and never closes the window. A scene that binds nothing to Escape keeps Escape as the quit
  key, as before; the built-in input map's `menu.cancel` does not count, so a scene with no input
  map of its own still quits on Escape. Whatever the game binds, **Shift+Escape** (the editor's
  Stop key) and **Alt+F4** quit, the window's close button quits, and so does a behaviour's
  `ctx.session.quit()`. The quit keys are asked before the UI, so a pause menu that takes Escape
  does not swallow them. A dismissible screen still takes plain Escape first, as it always has
  (docs/UI.md). The other window keys (F1, F3, F5, F9, F10) are unchanged. The rule is
  `window::window_key`, tested in `crates/app/src/window.rs` and
  `crates/app/tests/window_escape.rs`.
- **Sound.** A windowed run plays through the machine's default output device (the engine's `window`
  feature brings kira's cpal backend: WASAPI on Windows, ALSA on Linux). A machine with no device —
  no sound card, a server, WSL — still starts: the engine prints one line, `argus: no audio output
  device (…); playing silent, everything else runs`, and mixes into nothing. **WSL with WSLg** has
  a PulseAudio server (`/mnt/wslg/PulseServer`) that reaches the Windows speakers, but a stock WSL
  Ubuntu lacks ALSA's PulseAudio plugin, so ALSA has no `default` device and the game is silent;
  the line then says so and names the fix: `sudo apt install libasound2-plugins`, and if needed
  `pcm.!default pulse` and `ctl.!default pulse` in `~/.asoundrc`. A Windows export uses WASAPI and
  is not affected. `ARGUS_NO_AUDIO=1` in the environment silences a run on purpose, and a window
  silenced that way (or by `--no-audio`, or by serving the query API without `--audio`) says
  `argus: audio off: <why>` once; `--headless` and `--offscreen` never open a device.
- **Ports.** An exported game takes free ports for the query API and `/stream` by default, so it
  starts whatever else is listening on the machine; the engine's `argus-ready {…}` line, first on
  stdout, names them. `--port N` pins the query API (then `/stream` is on 7701 unless `--ws-port`).
- **Unpacking.** The game is read straight out of the executable (`--payload-exe`); only the
  engine (`bin/`) is unpacked, into `<root>/engines/<key>`, where `<key>` is a hash of `bin/`'s
  paths and SHA-256s, so every build and every game with the same engine shares one, and an update
  that keeps the engine unpacks nothing. `<root>` is `$ARGUS_EXPORT_HOME`, else
  `$XDG_CACHE_HOME/argus-export`, else `%LOCALAPPDATA%\argus-export`, else `~/.cache/argus-export`.
  `<root>/<id>` holds the game's log, crash reports, input overrides and a `.last-run` stamp. Every
  file is checked against its SHA-256 in a temporary folder renamed into place only when all match.
  An engine that cannot read the executable exits 75, and the launcher then unpacks the whole
  payload into `<root>/<id>`, as every launcher did before, and runs it from there; `--unpack`
  asks for that directly.
- **Pruning.** Once the game is running, the launcher removes unpack folders no launch used for
  30 days (never the current game or the two most recently launched others) and engines no kept
  game runs that were unpacked more than 30 days ago. Only folders whose own markers name them are
  touched; a folder in use is moved aside first and left whole if it cannot be.
  `ARGUS_EXPORT_KEEP=1` turns pruning off.
- **`--info`** reads the entry and the application record straight from the executable, checking
  the record's hash, and unpacks nothing.
- **`--extract-only`** unpacks and prints the folder; **`--print-dir`** prints it without unpacking.
- **`--notices`** prints the game's third-party notices from the executable, checking their hash,
  and unpacks nothing ([Third-party notices](#third-party-notices)). An export made before exports
  carried them says so and exits 2.
- **Logs.** Before the engine starts, a project export prints `launcher: application {…}` (the
  record) on stderr; the engine's own output follows.
- **On Windows** the launcher is a GUI-subsystem program: started from Explorer it opens no console
  window. Started from cmd or PowerShell it writes to that console, but the shell does not wait for a
  GUI program, so the prompt returns at once and the output appears after it. With its output
  redirected (a pipe or a file), it writes there instead. The engine runs with no console of its own,
  in a job that ends it when the launcher ends.

## Output paths

- **Without `--out`** an export goes to `dist/<job id>/<name>`, a new job folder each time
  (`newJobDirectory`). Two exports of one scene, one after the other or at the same time, never
  share a path. The report and the CLI print the job id.
- **With `--out`** the executable goes to `<out>/<name>`. While an export writes it, the file
  `<name>.lock` holds the host and process id, and a second export of the same executable is refused
  (`output-locked`). A lock left by a process on the same host that no longer exists is replaced; a
  lock from another host is never taken over (remove it by hand when no export is running).
- **Always**, the executable is written beside its destination under a temporary name, read back,
  and renamed into place only when it verifies: the trailer, the index, every payload file's hash,
  and on Windows every icon and version resource byte for byte, the section layout and the GUI
  subsystem. A failed check leaves nothing at the destination. `<name>.manifest.json` beside it is
  the payload index.

## The launcher cache

A branded Windows launcher links its application's resources, so the launcher is built per
branding: `cargo build --release` of `packages/export/launcher`, whose `build.rs` passes the `.res`
file named by `ARGUS_LAUNCHER_RESOURCES` to the linker. The result is cached in
`<cache>/launcher/<key>/`, with the `.res` and `key.json` beside it.

- **Cache root:** `ARGUS_EXPORT_CACHE`, else `$CARGO_TARGET_DIR/argus-export`, else
  `target/argus-export` in the repository.
- **Key:** SHA-256 over the launcher sources (`src/**`, `Cargo.toml`, `Cargo.lock`, `build.rs`),
  `rustc -vV`, the target triple, the profile, the `.res` hash (or none), `RUSTFLAGS`,
  `CARGO_ENCODED_RUSTFLAGS`, `CARGO_TARGET_<TRIPLE>_RUSTFLAGS`, and the linker's identity (a
  configured linker's `--version`; on MSVC the Visual C++ tools version link.exe comes from;
  elsewhere `cc --version`).
- **Read only.** A cached launcher is only ever read; each export composes a new file from its bytes.
- **Concurrency.** A launcher is built in a private folder and published by renaming it into place;
  two exports building the same key at once both use whichever landed first.
- A non-Windows launcher carries no resources, so every export for that target shares one cached
  launcher. Deleting the cache is always safe.

The engine is not rebuilt per export: `ARGUS_DEPLOY_BIN`, else
`<CARGO_TARGET_DIR or target>/export-engine/release/argus`, built on first use and reused only while
it matches this checkout. Beside it the exporter writes `argus.source`, a hash of the workspace's
`Cargo.toml`, `Cargo.lock`, `crates/**`, `vendor/**`, `packages/**` (less `node_modules` and `dist`)
and `docs/ui-components.json`, the files the engine is compiled from or embeds (plus the build's features). A cached engine
whose stamp is missing or names other source (an older checkout's build left in a shared target
folder) is rebuilt by cargo, incrementally, or refused as `engine-stale` when building is off. The
shader tool (`argus-export`) is cached the same way. An explicit `ARGUS_DEPLOY_BIN` is trusted as given. Its `window` and
`gamepad` features are read from the binary (winit's and gilrs's strings, and the message a build
without `window` carries) and reported as `engineFeatures`. `windowed` defaults to what the binary
shows, and a `windowed` option the binary contradicts is refused (`binary-mismatch`). Every engine
build, headless or windowed, is held to one 100 MB engine gate.

## Target support and export-time requirements

Exporting needs, at export time, Node with the workspace installed, cargo with the repository's
Rust toolchain, and for a Windows target the MSVC build tools (link.exe; lld-link also takes a
`.res`). The engine embedded must be a build for the same target.

| Host → executable | Branding | Status |
|---|---|---|
| Linux → Linux (`x86_64-unknown-linux-gnu`; `-musl` with `--launcher-target`) | name, `--info` record; no executable icon or version (Linux has no such resource) | built and tested |
| Windows (MSVC) → Windows (`x86_64-pc-windows-msvc`) | name, icon, version resource, `--info` record | built and tested (the Windows packaging smoke) |
| Windows → Windows GNU (`*-windows-gnu`) | refused for a project export (`target-unsupported`): its linker does not take a `.res` | not supported |
| Linux → Windows | not supported: no MSVC linker or Windows SDK on Linux, and the engine must be a Windows build | not supported |
| macOS | no executable icon or version is written; cross-built on Linux and bundled as a `.app` (see "The Apple targets") | built and signed ad hoc, never run |
| Web | a static folder: page, wasm runtime, scenes, assets, behaviours as JavaScript, manifest | built and smoke-tested in headless Chromium on a software WebGPU adapter; not run on a real GPU, Firefox or Safari |

Installers and macOS bundles are later packaging work. Signing and the Linux packages
are below.

### Signing

`--sign-cmd "<command>"` (`signCmd` from TypeScript) runs a command you supply; the exporter holds no
certificate and signs nothing itself. Write `{file}` where the path goes; it is quoted for the shell
for you, so `{file}` and `"{file}"` both work with spaces in the path. The command must sign the file in
place and exit 0.

```text
engine export my-game --target native --sign-cmd 'signtool sign /fd sha256 /a /tr <timestamp url> /td sha256 {file}'
```

It is run twice, in this order, each time on a copy named as it will be named in the export:

1. On the engine (`bin/argus.exe`), **before it is packed**. The payload carries those bytes, so the
   signature is part of the payload hash and of the unpack cache id.
2. On the finished executable, **after the payload is appended**. Authenticode hashes the whole file up
   to its certificate table, so the launcher's signature covers the payload, and anything appended
   afterwards would be unsigned. The export then reads the file back (`verify-failed` if it does not).

The certificate table sits after the payload's trailer, so every reader of the format, the
launcher's, `payload.ts` and `crates/export`, finds the trailer by looking through it: when the file
does not end with the trailer and is a PE with a security directory, the trailer is expected at the
directory's offset less at most seven zero bytes of alignment padding. A file with no signature is read
exactly as before. A command that appends to or rewrites the file instead of signing it breaks that
layout and is refused at the read-back, with a hint.

A failing command, one that cannot be started, or one that leaves no file fails the export with
`sign-failed`, the command's exit status and the end of its output, and writes nothing to the output
folder. `NativeExportReport.signing` lists what was signed, with each file's hash before and after.

Not verified anywhere yet: a real signature (signtool or osslsigncode on a real certificate), that
Windows accepts the signed file with a payload appended, and SmartScreen's verdict. The layout a
signature leaves is tested with a stand-in signer that writes what Authenticode writes.

### Linux packages

`--package tar`, `--package appimage` or `--package tar,appimage` (`packages` from TypeScript), for a
project export with a Linux target. The packages are written beside the executable and contain the
executable the export wrote (signed, if `--sign-cmd` was given). Names come from the project:
`application.displayName` for the menu entry, `application.version` and `application.executableName`
for file names, `export.appId` (`project-export-app-id`, ProjectCode 41; else derived from
`application.id`) for the desktop id and the icon name, and the brand module for any product name in the
text.

- **tar**: `<executableName>-<version>-linux-<arch>.tar.gz`, laid out as a prefix: `bin/<executable>`,
  `share/applications/<appId>.desktop`, `share/icons/hicolor/<n>x<n>/apps/<appId>.png` for 16, 24, 32,
  48, 64 and 256 (the export's own icon sizes that hicolor has a folder for), and
  `share/doc/<executable>/` with a README and the third-party notices.
  `tar -C ~/.local --strip-components=1 -xzf <file>` installs it for one user; unpacking anywhere and
  running `bin/<executable>` works too. The bytes are reproducible: sorted entries, no owners, no
  timestamps, a fixed gzip header.
- **appimage**: an AppDir (`AppRun`, the desktop file and icon at the top, the same prefix under `usr/`)
  and, with `appimagetool` on `PATH` or named by `<ENV PREFIX>APPIMAGETOOL`, the `.AppImage` made from
  it. Without the tool the AppDir stays in the output folder and the package reports `NOT-BUILT` and what
  was missing. Nothing is downloaded. A tool that is found and fails is `package-failed`.

`NativeExportReport.packages` lists each package with its path, size and SHA-256 (or the `NOT-BUILT`
reason). A scene export, a non-Linux target or an unknown kind is refused before anything is built.

The glibc an export needs is the glibc of the machine that built the engine and the launcher: a
release built on a current distribution does not start on an older one. Linux release binaries are
therefore built in Valve's Steam Runtime "sniper" SDK container (glibc 2.31), and the release matrix
records the floor it finds and warns above 2.31 (`docs/RELEASE.md`, "Linux release build"). Steam uses the plain executables,
not these packages.

### Two engine builds

A project's `render.profile` (docs/PROJECT-MANIFEST.md) picks which engine build an export packs.
The SDK and the installed editor carry both:

| Build | Cargo features | CLI (`engine export`) finds it at | Beside the editor | Packs |
|---|---|---|---|---|
| standard | `deploy,window,gamepad` | `$ARGUS_DEPLOY_BIN`, else `<target>/export-engine/release/argus` (built there if absent) | `argus-deploy-engine.exe` (`install-editor.ps1 -Deploy`) | projects with no `render.profile` or `"standard"`, and scene-only exports |
| HD | `deploy,window,gamepad,hd` | `$ARGUS_DEPLOY_HD_BIN`, else `<target>/export-engine-hd/release/argus` (built there if absent) | `argus-deploy-engine-hd.exe` (`install-editor.ps1 -DeployHd`, optional) | projects whose `render.profile` is `"hd"` |

- **The builds are told apart by their bytes.** The HD build carries the string
  `argus-render: HD engine build` (`HD_ENGINE_MARKER` in both exporters), which only
  `argus-render`'s `hd` feature compiles in. Both exporters read it out of the engine they are given.
- **The engine must match the project, both ways** (`binary-mismatch`): an HD project with the
  standard engine would ship a game that does not look like its author's, and a standard project
  with the HD engine would ship HD code it never runs (HD off pays nothing).
- **An HD project with no HD engine installed** is refused in the editor's Export panel before
  anything is composed, naming `argus-deploy-engine-hd.exe` and `-DeployHd`, with the other way
  out: set the renderer to Standard in Project Settings.
- The payload names the engine `bin/argus(.exe)` either way; a player never sees which build it is.
- Both builds are built by `scripts/ci/linux-gate.sh` and `scripts/windows/ci-gate.ps1` (release
  stage), and both are held to the same 100 MB engine gate.
- A development export of an HD project uses the HD development engine
  (`deploy,window,gamepad,serve,hot-reload,hd`: `$ARGUS_DEPLOY_HD_DEV_BIN`, else
  `<target>/export-engine-hd-dev`).
- `scripts/release/matrix.sh` has an `hd` row: it builds the HD release engine for Linux (and
  Windows when it builds Windows), exports the project with `render.profile` set to `"hd"`, and
  smokes that export offscreen. The row records the HD engine's hash, size and size gate. It is a
  variant of the same game, so it is kept out of the payload-identity check.

## When a change takes effect

Everything in the payload is fixed when the executable is written:

| Change | In an exported executable |
|---|---|
| `displayName`, `executableName`, `version`, `buildNumber`, `icon`, `startupScene`, `window` | re-export |
| the scene, its assets or behaviors | re-export |
| the engine or the launcher sources | re-export (a new launcher key, a new payload id) |
| `--headless`, `--offscreen`, `--port`, `--ws-port`, `--scale`, `--telemetry`, `--stuck-after`, `--player` | the next run of the executable; nothing to rebuild |

While editing a project, which settings apply live in `argus run` and the editor, and which need a
restart, is S2 and S3's (`docs/PROJECT-MANIFEST.md`, `docs/EDITOR.md`). Nothing in a running
exported game changes while it runs.

## Errors

`ExportError.code`, stable across messages:

| Code | Meaning |
|---|---|
| `usage` | contradictory options (both or neither of scene and project; `--development` on a scene; a bad `--name`), a missing referenced file, or a file name Windows cannot hold ([File names](#file-names)) |
| `project-invalid` | the project fails validation for the export's purpose; `details` holds the violations |
| `icon-decode` | the icon cannot be decoded, is too large, or is not square |
| `version-range` | a version part does not fit 16 bits |
| `target-unsupported` | a branded export for a Windows target that is not MSVC |
| `binary-missing` | no engine or launcher, and building is off or `ARGUS_DEPLOY_BIN` names nothing |
| `binary-mismatch` | the engine or launcher is not an executable for the target (PE, ELF, Mach-O), `windowed` contradicts the engine binary's window feature, or the engine build does not match the project's `render.profile` ([Two engine builds](#two-engine-builds)) |
| `size-gate` | the engine is over the 100 MB engine gate; or, for `--target web`, the runtime plus the startup scene's files are over the 8 MB gzipped download gate (the message starts `web-size-gate:` and names the largest files) |
| `launcher-build` | rustc or cargo failed |
| `output-locked` | another export is writing the same executable |
| `verify-failed` | the composed file, or the bundled `app/` project, did not read back as written |
| `cancelled` | the export was cancelled between stages or inside the asset loop; nothing was written (`crates/export` only) |
| `resource-slot` | a branded icon or version block does not fit the launcher template's fixed slot, so the generic icon and version are kept (`crates/export` only) |
| `sign-failed` | the `--sign-cmd` command failed, could not be started, or left no file (`packages/export` only) |
| `package-failed` | `appimagetool` was found and failed (`packages/export` only); a missing tool is a `NOT-BUILT` package, not an error |
| `engine-stale` | a cached deploy engine or `argus-export` in the target folder was built from other engine source than this checkout (its `.source` stamp is missing or differs) and building is off; with building on it is rebuilt instead (`packages/export` only) |

The list is the same on both sides: `ExportErrorCode` in `packages/export/src/errors.ts` and in
`crates/export/src/errors.rs`, compared by the parity tests. `cancelled` and `resource-slot` exist in both
so that a caller handles either exporter's refusals the same way, even though only the Rust one raises
them; `sign-failed`, `package-failed` and `engine-stale` are the reverse: only the TypeScript exporter raises them.

## Checking an export

- `--info` for the record and the payload id; `--extract-only` and the index in
  `<name>.manifest.json` for the files and their hashes.
- On Windows, `packages/export/test/windows/inspect-exe.ps1 -Exe <exe> [-Png <icon>]` prints what
  Windows reads: FileVersionInfo, every icon and version resource, each icon image as
  CreateIconFromResourceEx and GetDIBits decode it, and the PNG as GDI+ decodes it. It opens no window.
- `packages/export/test/windows/run-windows-gate.sh <engine.exe> <work folder>` (from WSL) runs the
  whole Windows packaging smoke headless. It checks that a headless run serves the query API, so give
  it the development engine (`export-engine-dev`).
- `scripts/release/matrix.sh` builds the release engine for Linux and Windows, exports the same game
  for both, smoke-tests each with `--self-test`, checks that every payload file but the engine hashes
  the same on both, and writes `dist/release/<version>/MATRIX.json` with explicit NOT-SHIPPED rows
  for the platforms this machine cannot build.

## From the editor

The editor has an **Export…** button (`docs/EDITOR.md`, "Exporting a game"). It is the same composition this document describes, with three differences that matter.

**No toolchain, and nothing built.** The installed editor has no cargo, no Node and no checkout, so it never runs the two cargo builds the CLI may run (`deployEngineBinary`, the launcher build). Both binaries are installed beside `argus-editor.exe` instead:

| Beside the editor | What it is | Installed by |
|---|---|---|
| `argus-deploy-engine.exe` | the `deploy,window,gamepad` engine | `install-editor.ps1 -Deploy <exe>` |
| `argus-deploy-engine-hd.exe` | the `deploy,window,gamepad,hd` engine, for HD projects; optional | `install-editor.ps1 -DeployHd <exe>` |
| `argus-launcher.exe` | the prebuilt launcher template, with the fixed resource slot | `install-editor.ps1 -Launcher <exe>` |
| `<name>.sha256` | the hash the installer recorded, `sha256sum` form | written for each |

Both are read and **checked against their recorded SHA-256 by their bytes** before a byte of payload is hashed, so a swapped or corrupted template or engine is refused (`binary-mismatch`) rather than composed in. Neither check is optional here: an install whose `.sha256` file is missing, or is in a spelling the reader cannot parse, is refused by name with the flag that writes one, because a check that silently turns itself off is worse than no check at all. A missing binary is a sentence in the panel naming the file and the flag that installs it, never an attempt to build one.

**Compressed deploy engines.** A Launcher install ships each deploy engine as `argus-deploy-engine.exe.zst` (and `argus-deploy-engine-hd.exe.zst`) with no plain `.exe`: an engine is an export template, never run from the install, and zstd takes about a third off its size. The `.sha256` beside it is still the hash of the **plain** executable. The first export that needs an engine unpacks it (well under a second) into `%LOCALAPPDATA%\Argus\cache\deploy-engine\<sha256>\` and verifies it against that hash; later exports read the cached copy, and a cached copy whose bytes do not hash right (damaged, truncated, half-written by a crash) is unpacked again. Two exports unpacking at once cannot see each other's half-written file (temporary file, then rename), and an install folder that is read-only works because the cache lives in the user's folder. The cache keeps the four most recently used engines (a hit refreshes an entry; a fresh unpack removes older ones). A cache that cannot be written is not an error: the export uses the verified bytes it already has. The engines are stored as a difference from `argus.exe` when the release has one (`argus-deploy-engine.exe.delta`: a header naming the base file and its SHA-256, then a zstd frame made with the base as a reference prefix); unpacking refuses a base that does not hash to the header, and uses a plain `.zst` beside it if there is one. The HD engine is a difference from the standard engine instead (`argus-deploy-engine-hd.exe.delta` names `argus-deploy-engine.exe`), its nearest copy and about 1.5 MB smaller: when that base is only there packed, it is unpacked first, through the same cache and against the hash the header records. One level only: the standard engine's own base must be a plain file. A plain `argus-deploy-engine.exe` beside a `.zst` always wins, so a dev checkout or an older install behaves as before. `argus-export --pack-engine <exe>` writes the `.zst` (the release scripts call it after signing and hashing).

**An export never lands on its own tools.** The destination is refused if it is the launcher template, the deploy engine, or anywhere inside the folder they are installed in. The template is read into memory before anything is written, so exporting over it used to *succeed*, and every later export then composed from a finished game.

**A release export needs an application icon.** `application.icon` is required for a release build by the contract, and the exporter holds a Windows export to it: with no icon it is refused (`project-invalid`, with the contract's `project-icon-required`) rather than shipped carrying the launcher template's grey placeholder. A development export (`argus-export --development`) may keep the placeholder, and says so in a note the panel shows. The editor only makes release exports.

**The inputs are a revision, not a working tree.** The CLI exports what is on disk now. The editor exports the last build the workspace service found **Ready**: the revision's read-only snapshot (`.argus/snapshots/<revisionId>/`) for the manifest, scene, icon and assets, and the build job's output (`.argus/jobs/<jobId>/out/modules/`) for the behaviours that build already transpiled. No esbuild runs, and nothing the build did not check goes into the executable. With no Ready build there is nothing to export and the panel says so in the build view's own words.

**One resolver, shared with Play (C2).** `export::resolve` is `crate::artifact::LoadDescriptor::resolve` — the same call the editor's Play and its test workers make — with the export's own additions on top: the application icon, the branding record and the Windows version block, none of which a running world reads. So "the exported game runs what Play ran" is a comparison of two loaded-byte manifests rather than a claim, and every refusal about missing content carries the same stable code in both places (`content-missing`, `content-incomplete`, `content-unverified`, `modules-missing`, `typecheck-skipped`).

**The content root is the service's verified answer.** `GET /v1/builds/{revision}/content-root` re-materialises the revision's snapshot and hashes every file in it against the revision's own inputs before answering. An export composes from that answer or it does not compose: an executable a person will double-click is not composed from a path assembled out of a revision id.

**A build whose typecheck was skipped is not exported.** C1 records `"typecheck": "skipped"` for a build made with the developer bypass (`docs/SDK.md`). It is Ready and it is visibly unchecked output, and the two are not the same thing, so the checked gate refuses it with the repair rather than shipping it. `"checked"` and `"not-needed"` both pass: a project with no TypeScript in it has nothing to check.

**The exported binary keeps its own runtime identity.** The editor and the deploy engine run the same checked content and are not the same executable. The descriptor's `content_id` is shared with Play; its `runtime_id` hashes the deploy engine's recorded binary and the `deploy-export` profile, so it is not. A deploy binary is never relabelled with an editor artifact id, and a verdict reached in the editor is not reused for it.

**The output names its revision.** `revisionId`, `artifactId` and `buildId` go into the payload entry and the branding record, so they hash into `index.id` and come back out of the file through `--info`. The panel shows the composed executable's SHA-256 and its payload id when it finishes.

**What a failure left behind, per failure.** Composing writes a temporary file beside the destination and renames it only once it verifies (see [Output paths](#output-paths)), so a refusal or a failure during composition leaves a previous export exactly as it was, and the panel says so. It is not true of *every* failure, and the panel no longer claims it is: the executable is renamed into place and only then read back to hash it, and a failure of that read — a virus scanner holding a freshly written exe is the ordinary Windows case — comes after the previous output has already gone. The exporter reports which of the two happened and the panel repeats it.

**Cancel.** The editor's export runs on a thread and reports progress by stage through a channel; Cancel sets a flag the composer checks between stages and inside the asset loop, and the export ends with the `cancelled` code having written nothing. The temporary file is removed by a guard that runs however the composition ends, including the last window of all — after the output has verified and before the rename — where a cancel used to return with the file still on disk under a name nothing would ever write again. A cancel *after* the rename is ignored on purpose: "cancelled" must never come to mean "your previous export was deleted".

## The web target

`engine export <scene | project> --target web [--out dir] [--web-runtime dir] [--zip] [--precompress] [--json]` writes `<out>/web/`, a
static folder any web host serves as it is. A scene exports as one scene; a project exports every scene
it has, booting into its startup scene.

| In the folder | What |
|---|---|
| `index.html` | the page. It checks for WebGPU before fetching anything and, where the browser has none, shows "This page needs WebGPU, which this browser does not offer." instead of a blank canvas. Its names come from the brand module. Its title, canvas and progress bar are plain HTML and CSS, so they paint from the first response (Start-up (see "Start-up" below)) |
| `<id>.<hash>.js`, `<id>.<hash>.wasm` | the runtime (`crates/web`, built by `scripts/web/build.mjs`) and its glue, named by the first 16 hex digits of their SHA-256 like the assets; the page and the manifest name them |
| `app/scene.json`, `app/<project path>` | a single scene at `app/scene.json`; a project's several scenes each at its project path (the startup one first in the manifest), the path a script's `loadScene` names |
| `app/assets/<hash>.<ext>` | assets under the first 16 hex digits of their SHA-256, exactly the names a native export uses |
| `gameplay/modules/*.js`, `gameplay/entries.json` | behaviours, already JavaScript, run by the page's own JavaScript engine (see "Behaviours on the web" below) |
| `README-hosting.txt` | for whoever hosts it: serve over http(s), not `file://`; WebGPU; the CSP `'unsafe-eval'` behaviours need; no COOP/COEP; `.wasm` as `application/wasm` |
| `manifest.json` | every file with its size, SHA-256 and gzip size; the files every scene needs (`shared`); each scene's file list (`closure`); the runtime file names; the font; the download figure and its limit |

The files are the native exporter's own: the same hashing, behaviour bundling, prefab and `ui/`
handling, at the same paths. A project's test scenarios (`app/selftest/`) are left out.

**The download gate.** What a visitor fetches before the first frame (the page, the runtime and every
file in the startup scene's closure), each file gzipped alone at level 9, must be under 8 000 000
bytes. Over it, the export fails with `size-gate` (message prefix `web-size-gate:`, the largest files
named) and writes nothing. The figure is in the manifest's `download`.

**The other budgets** (the constants in `packages/export/src/web/gate.ts`). The whole export over 12 000 000 bytes gzipped
is a warning (`web-export-large`), and over 200 000 000 bytes raw a refusal (`size-gate`). The runtime module
has a ratchet, its gzip at most `WEB_RUNTIME_RATCHET_GZIP_BYTES` (`packages/export/src/web/gate.ts`). That
ratchet is checked where the runtime is built (`scripts/web/build.mjs`, and so the gate), never at a user's
export. A commit raises it with a one-line reason.

**The navmesh.** The browser build has no Recast, so a scene that declares navigation (`meta.nav`, or a
`navagent`) has its navmesh baked at export by the native engine (`argus nav-bake <scene>`, the same plan a
load builds). It is written beside the scene as `<scene file without .scene.json>.navbake`
(`app/scenes/main.navbake`), listed in the manifest and in that scene's closure; the web runtime's crowd reads
it by that path. The engine is `ARGUS_BIN` or a checkout's build, as for every command that runs one;
without one, a scene with navigation fails the export with `binary-missing`. A scene with no static collider
geometry gets no bake (a log line: its navagents stand still), and a tiled navmesh is refused
(`project-invalid`, message `navmesh-tiled: …`) until the web runtime reads tiles.

**For the host.** `--zip` also writes `<out>/<name>-web.zip`: the folder's files with `index.html` at the
root, the form an itch.io HTML game or a drag-and-drop host takes. It is deterministic: the same files give
the same bytes. `--precompress` writes `.br` (brotli 11) and `.gz` (gzip 9) beside each file worth it, as
`engine deploy web --precompress` does, for a host that serves them as they are.

**`--json`** (the editor's and an agent's way in) prints one JSON object per line on stdout and nothing else:
`{"event":"progress","stage","done","of"}` for `validate`, `bundle`, `gate`, `write`, then `precompress`
and `zip` when asked for. Then exactly one `{"event":"report", folder, zip, zipBytes, firstDownloadGzip,
runtimeGzip, files, deferred, runtimeVersion, runtimeGitSha, budgets, precompressed, warnings}`, or
`{"event":"error", code, message, violations}`. The exit status is 0 or 1. Log lines go to stderr.

**Behaviours on the web.** The runtime runs a scene's behaviours: the page passes every file of the startup
scene's closure to the runtime (`start_files`, a map of folder-relative path to bytes), and the runtime reads
`gameplay/entries.json` and `gameplay/modules/<name>.js` from it, so a scene that names a behaviour in a
`script` component runs it, with the same `ctx` as a desktop build for the members the web host serves. A module the map lacks is one script error on that entity, as on the desktop.
Behaviours are built with `new Function`, so a page needs `'unsafe-eval'` in its Content-Security-Policy:
`engine deploy web`'s `_headers` sets one that allows it. A host that serves a stricter policy (no
`'unsafe-eval'`) still shows the scene, but with behaviours switched off and a console warning saying
so. There is no navmesh in the browser build.

**Input on the web.** The page forwards the keyboard, the mouse and the first standard-mapping gamepad to the engine's
own device layer, so the actions and bindings in a scene's `meta.input` work as on the desktop (`W`, `Space`, a
`mouse: "delta"` look, `gamepad: "left-stick"`). A click on the canvas takes pointer lock, which is what turns mouse
movement into the camera's look; leaving the lock, or the window losing focus, releases every key. Pad buttons are
read by position (south, east, ...), as on the desktop. A finger is a touch for the game's touch regions and, where no region owns it, the UI's pointer; a mouse click presses a menu button the same way. The page's `touch-action` is off so a finger does not scroll it. The engine's own messages and a behaviour's `ctx.log` lines go to the browser console.

**Start-up.** The page starts the runtime's module (streamed into the compiler), its glue and `manifest.json`
downloading together, and the startup scene's closure the moment the manifest is in, so those files arrive while the
module is still compiling instead of after it. The progress bar counts bytes received (the module as it
streams, the closure as each file lands) over the bytes the page needs (the module's size is written into the page at
export, the closure's comes from the manifest); it is held under 100% while the runtime compiles, opens the GPU and loads the
scene, then goes away. The module is the download that matters (about 2.7 MB brotlied, 3.8 MB gzipped), so serve it brotli
compressed: Cloudflare does so itself, other hosts need it switched on (`engine deploy web <dir> --precompress` writes a `.br`
and a `.gz` beside every file they shrink, for nginx `brotli_static`/`gzip_static`, Caddy `precompressed` and the like; verify
checks each is its file's bytes). The module is immutable and content-hashed, so a repeat visit reads it from the browser cache.

**Replacing an export.** `<out>/web/` is replaced only when it holds an earlier web export (it has
`manifest.json`); any other folder of that name is refused (`usage`). The folder is written beside
its destination and renamed into place, so a failed export leaves no half-written page.

**Testing it.** `node scripts/web/smoke.mjs --dir <out>/web` serves the folder on localhost, opens
`index.html?self-test` in headless Chromium (new headless with Vulkan and SwiftShader, which gives a
software WebGPU adapter that can present to a canvas on a machine with no GPU; the flag set used is printed), and reads the page's own self-test: the scene
loaded, frames presented, the simulation ticked, the progress bar counted every byte, the frame read back and not blank, the GPU finishing the first frame, and no console error or failed request. It serves the module slowly (256 KB every 25 ms) and fails if the startup closure was not asked for while the module was still downloading. Exit 0 passed, 1 failed, 3 cannot
run here. The release matrix's `web` row (`scripts/release/matrix/web.sh`) runs the same export and
smoke, and says `NOT-SHIPPED: <missing tool>` where the tools are not installed.

**Deploying.** `engine deploy web <out>/web` verifies the folder against its manifest, writes
`_headers` (hashed files cached a year and immutable; only the page and manifest revalidated; the
module as `application/wasm`; a Content-Security-Policy that allows `'unsafe-eval'` for behaviours) and `wrangler.jsonc` beside the folder for Cloudflare's static assets, and
prints the upload command. It never uploads and makes no network call; whoever owns the Cloudflare
account runs the command.

## The Apple targets

`engine export <project> --target macos|ios [--out dir] [--name x] [--development]` exports a
**project** (a scene alone has no bundle identifier). Neither target signs, notarises or builds
anything itself. The macOS engine can be cross-built on Linux (below) and the matrix's `macos` row says so
(`BUILT-NOT-RUN`); it stays short of `SHIPPED`, and `ios` stays `NOT-SHIPPED`, until a Mac runs them.

**macos** writes `<out>/<displayName>.app` (default `dist/macos/`): `Contents/MacOS/<executableName>`
(the engine itself, mode 0755), `Contents/Resources/app/` (the payload as plain files, laid out as
`payload.ts` lays it out, never appended to the executable: `codesign` seals files, not trailing bytes),
`Contents/Info.plist`, `PkgInfo` and `Resources/AppIcon.icns` from the project's icon. Started by Finder
(no arguments) the engine runs `Resources/app/app` in a window; given arguments it behaves as always.
Built off a Mac the engine is a **stand-in** (not a Mach-O): the layout is right, the app will not run,
and the output says `STAND-IN ENGINE`. Pass `--engine <Mach-O>` for a real one.

`Info.plist`'s `LSMinimumSystemVersion` is the engine's own minimum: the highest `minos` in the Mach-O's
`LC_BUILD_VERSION` over every slice of a universal file (`LC_VERSION_MIN_MACOSX` for an older linker), so
Finder never offers an app that dyld will then refuse. A stand-in or a binary naming no minimum gets `11.0`.

### Building the macOS engine on Linux

`scripts/release/macos-cross.sh` (the matrix's `macos` row; run it alone with `REPO`, `OUT` and `LOG` set)
builds the engine for `aarch64-apple-darwin` and `x86_64-apple-darwin` without a Mac or Apple's SDK,
joins them with `lipo`, exports the `.app` around the result, signs it ad hoc with `rcodesign` and checks
the structure (architectures, bundle layout, plist minimum against the binary's, signature, payload sealed
in `_CodeSignature/CodeResources`). Tools, all user-local: `zig` (0.15), `cargo-zigbuild`, `rcodesign`
(apple-codesign), LLVM's `llvm-lipo` and `llvm-objdump`, and both Darwin rust targets on the pinned toolchain.
Without one of them the row is `NOT-BUILT: <what is missing>`.

How it links: zig is the C, C++ and linker (this is also what fixes the Recast/`cxx` C++ build that fails
with a Linux `c++`); the system frameworks are **empty stub `.tbd` files written at run time** (install
names only: nothing is copied from Apple and nothing is committed), and `-undefined dynamic_lookup` leaves
the symbols for dyld to bind when the app starts. `-headerpad` leaves room for the signature (zig does not
sign the x86_64 slice). Set `MACOS_SIGN_P12` and `MACOS_SIGN_P12_PASSWORD` to sign with a Developer ID
identity (hardened runtime) instead of ad hoc.

**Unverified until a Mac runs it:** that the engine starts at all (no Mach-O has been executed; a symbol
dyld cannot find, the Metal device, the window and the Finder launch path are all untested), `codesign
--verify --strict`, Gatekeeper, and notarisation (needs a Developer ID certificate and an Apple account).
A release build should be made on a Mac with the real SDK; this route is a structural proof. Apple's
Xcode licence limits its SDK to Apple hardware, so the route deliberately does not use one.

**ios** writes `<out>/<executableName>-ios/`: `Info.plist` (landscape, full screen; the local-network
strings only with `--development`), `PrivacyInfo.xcprivacy`, `app/` (the payload), an XcodeGen
`project.yml` and `NEEDS-MAC.txt`. It is not an app: a Mac runs `xcodegen generate` and `xcodebuild`
with `Engine/libengine.a` (the engine for `aarch64-apple-ios`) and the `Shell/` entry, neither of which
exists without a Mac.

The bundle identifier is `effectiveExportAppId` (`export.appId`, else derived from `application.id`),
held to the same rule as Android's (ProjectCode 41, `project-export-app-id`): two or more segments of
`[A-Za-z][A-Za-z0-9_]*`, at most 255 characters, no Java keyword. A failure is `project-invalid`.

## Third-party notices

An exported game is somebody else's copy of the engine, so it carries the licence texts of the
third-party code in it. They travel **inside the executable**, as `THIRD-PARTY-NOTICES.txt` at the
payload's root, rather than as a file beside it: an export is one file that a person copies,
uploads or mails, and a second file beside it would be lost the first time. Two ways to read them,
neither needing anything installed:

- `<game> --notices` prints them, straight out of the executable (nothing is unpacked);
- the first run unpacks them with everything else, to `THIRD-PARTY-NOTICES.txt` in the unpack
  directory (`--print-dir` names it), beside `bin/` and `app/`.

**Where they come from.** Each deploy engine is installed with its notices beside it:
`argus-deploy-engine.exe` has `argus-deploy-engine.notices.txt`, the HD build
`argus-deploy-engine-hd.notices.txt` (docs/RELEASE.md, "Third-party notices", has the generator and
the policy). They cover the engine's crates for its exact features and target, the launcher (std
only), the engine's fonts and fallback faces, and for the HD
build the notices of the shaders translated from AMD's FidelityFX SDK and Intel's XeGTAO. Both
exporters pack that file's bytes unchanged, so the payload ids agree (`crates/export/tests/parity.rs`).

- **The editor** reads the file beside the engine it packs. A **release** export without one is
  refused (`binary-missing`, naming the file); a development export goes without.
- **`engine export`** takes `notices` (a path) if given, else the file beside the engine, else writes
  them from the checkout with `scripts/notices/notices.mjs` for the features the engine binary shows
  and the export's target. With no file and no toolchain (`noToolchain`), a release export is refused
  the same way.
- **`argus-export`** takes `--notices FILE`, else the file beside `--engine`, on the same terms.

A project's own fonts, sounds and models are the author's to license; the notices name only what
Talos puts in the game.

## The shader pack and pre-warm lists

A project whose materials name a `*.shader.wgsl` ([SHADERS.md](SHADERS.md)) exports with two more kinds of file
under `app/shader-pack/`, written by `argus_export::shaders::build` (the editor's export calls it;
`crates/export/src/shaders.rs` has the full rules). A project with no shader-using material gets
**no** extra file and exports byte for byte as before.

| Path | What |
|---|---|
| `shader-pack/<hash>.family.json` | one per shader family, content-addressed: the validated user part (comments and whitespace stripped), the parsed header, the hash and the engine's shader ABI and prelude versions. About 1-4 KB; two scenes (or two shader files with the same code) share one |
| `shader-pack/index.json` | how a shipped game finds the family of a material's `shader`, since the payload carries no `*.shader.wgsl`: shader file project paths (forward slashes) -> family hash, and `content` names -> family hash (`<first 16 hex of the shader file's SHA-256>.shader.wgsl`, and `.wgsl` for a scene shader asset) |
| `shader-pack/prewarm/<scene path>.json` | one per scene: every `(family hash, wrapper variant, double-sided)` pipeline the scene can need, nearest to the start camera first. A standard project lists the standard renderer's `main`, `coverage` and `shadow` entries; an HD project lists HD's own, `gbuffer` (one per use: skinning, sidedness) and, for a mask that casts shadows, `gshadow` (its cut-out in the shadow passes). The engine builds HD's pipelines from them behind the load (HD builds every sidedness and skinning of a family together, so one entry builds them all) |
| `shader-pack/screen/<hash>.json` | one per scene that lists `meta.render.screenEffects` (both profiles: HD draws them too): each effect's normalised shader, header, the params already packed from the scene's entry and its texture references. The name is a hash of the scene's folder and its listed entries, which the shipped engine recomputes, so it needs no scene path. A native shipped engine builds the effects from it at load (a web export ships the effect's source file instead) (docs/SHADERS.md section 11); an effect that does not validate refuses the export with the shader's file and line |

**Where user shaders ship.** A native export, standard or HD, ships them: the shipped engine loads this pack (below). The pack is compiled for the project's render profile, read from its manifest (`render.profile`): an HD project's families carry HD's variants and its pre-warm lists name HD's pipelines; the family files and the index are byte-identical to the standard export's of the same project (only the lists follow the profile). The export refuses a project in which a material file names a `shader` (`project-invalid`, one violation per material, naming the material and the shader) for a web target (the web bundle carries neither the pack nor a shader file named by path), and, for an HD project, a family HD cannot draw in a shipped game yet: the message names the material, the shader and why (today `vertex()` only: stylized, `blend: blend` / `add` and `reads: depth` families draw in HD's forward passes and ship, with a `forward` pipeline entry in the pre-warm list; a family used as an emitter's particle material is not refused: HD draws it with the standard renderer's particle pipeline, and the pre-warm list carries the same `particle` entry as a standard export; the list is whatever `argus_shader::wrapper::gbuffer_support` and `forward_support` say, so a family HD learns to draw stops being refused without a change here). A family HD draws with a gap (ray-traced hits that still see its fallback colours, per-entity floats, `transmission`, `tonemap: off`) exports with a named build warning `build.shader-hd-partial`, not silence. Screen effects are never refused: they ship in the pack (`shader-pack/screen/`) on a native export, HD included, and as their source on a web export. `argus-export --shader-pack` on its own (the dev tool) writes the pack anyway; the game export passes `--refuse-user-shaders`; `--shader-check` and `--shader-query` are unaffected.

**Refusal.** Every shader is compiled at export: header, assembly and naga validation of every variant
it can be asked for (static, skinned, shadow, coverage), the sandbox lint and the cost caps. An error
refuses the export (`project-invalid`, with the contract's `shader-*` code, file, line and column in
the details; a material naming a missing file is `material-shader-missing`). The game never validates
a shader: it checks a record's hash, ABI and prelude version (`FamilyRecord::from_bytes`), assembles
the engine's wrapper around the shipped user part and creates the pipeline.

**A shipped game loads the pack; it has no shader source and no front end.** The engine built for shipping
(`--no-default-features --features deploy,window,gamepad`, or with `hd` for an HD project; no `authoring`) has no WGSL parser or validator, and the
payload holds the material files and the pack, not the `*.shader.wgsl` files. For a material file with a `shader`
(named by path from the material file, or by a scene `shader` asset id) the app resolves the shader's project path
through `shader-pack/index.json`, reads the family record (hash, ABI and prelude checked, nothing parsed; the web
family budget applies on phones), registers the family and binds the material's `params` and textures from the header
the record carries. A shader that is not in the pack is a load error naming the material and the shader (with
placeholder assets on, as for every other asset failure, one warning and the file's plain material); the usual cause
is a material the export's scan did not reach (see the limits below). When the scene loads, its pre-warm list queues
every pipeline (skinned and double-sided variants included) on the workers behind the load, so nothing is created
while playing (`created_after_load` stays 0). The stock car modules need no file: the engine builds their family in
code. A payload that keeps project paths (the editor's export) is looked up by the shader's project path. `engine export` (the TypeScript exporter) renames material files, so it rewrites each material's path-form `shader` to the shader's content name (`<sha256 of the shader file, 16 hex>.shader.wgsl`, resolved from the material's own folder in the payload) and ships a scene shader asset under `assets/<hash>.wgsl` without its source; the index's `content` names find both, and two shaders never share a name. It runs `collectProjectForGame`, tested on a committed payload folder, `fixtures/shipped-shaders-ts`. The test
is `crates/app/tests/shipped_shader_pack.rs` on `fixtures/shipped-shaders`, a committed exported game folder
regenerated by `scripts/regen-shipped-shaders-fixture.sh` from `fixtures/shipped-shaders-src`; the export crate's
suite checks it is what the game export's tool writes today. The HD stage (`--features deploy,window,gamepad,hd`) runs
an exported HD project (`fixtures/shipped-shaders-hd`, from `fixtures/shipped-shaders-hd-src`; the TypeScript exporter's
is `fixtures/shipped-shaders-hd-ts`): a user surface shader and a mask family draw their own colours in HD's G-buffer
pass from the pack, with no shader source in the payload, nothing created after the load and no fallback draw. The
same project exported with its profile set to standard (`fixtures/shipped-shaders-hd-std`) draws the same colours on
the standard renderer: one project, both profiles, no content diff but the pre-warm lists.

**Run time.** `argus_shader::pack::load_scene` reads a scene's list and families from the payload;
`argus_render::prewarm::request_prewarm` turns the list into `PipelineRegistry::request_render` calls
(built on the registry's workers behind the loading screen) and `finish_load` waits and calls
`mark_loaded`, after which a creation is a `created_after_load`.

**What the list contains, and its limits.** The scan reads scene JSON, prefabs (placed, and any
`*.prefab.json` string literal in a bundled behaviour), materials (any component with a `material`
field; a behaviour literal naming a scene material or ending `.material.json`) and GLB `skins` (static
or skinned). Not found: paths or ids built at run time, parents' transforms in the ordering, and a
skinned use of a material a script applies. Such a pipeline is still created on first use (counted in
`created_after_load`; the draw uses its fallback). Both the main and coverage variants of a `blend: mask`
shader are listed, because which one is drawn depends on the MSAA setting.

**Warning.** `build.shader-scene-prewarm-heavy` (shown in the Export panel's note) when a scene's
predicted pre-warm (pipelines x `PREWARM_MS_PER_PIPELINE` = 28 ms cold per pipeline / 8 workers, from the P0
spike on the RTX 5080) exceeds 400 ms, about 114 pipelines. A `*.shader.wgsl` with `//! kind: pass` is not exported (nothing in a project runs pass shaders yet): both exporters add one `build.shader-pass-not-exported` warning per such file, naming it. **Both exporters write the same pack.** The editor calls `argus_export::shaders::build`; the TypeScript
`engine export <project>` runs `argus-export --shader-pack <project> --shader-scene SRC[=DEST]... --out <dir>`
(`$ARGUS_EXPORT_BIN`, else `target/release/argus-export`, built with cargo like the deploy engine) while
gathering, and packs its files under `app/`; a project with no `*.shader.wgsl` runs nothing and needs no binary.
The TypeScript exporter packs the startup scene as `scene.json`, so its list is `shader-pack/prewarm/scene.json.json`.
**Cost caps:** `--shader-target desktop|web` (the stricter column applies; the editor and `engine export --target native`
are desktop), `--shader-raise-units` and `--shader-raise-fetches` (a project's raised hard caps). The exporter reads the
project manifest's `render.shaders` (`costCapUnits`, `costCapFetches`, and `raw` for raw and pass files); the flags
override it.

## Textures cooked ahead of time

A project with `render.compressTextures` on (the default: absent is on) compresses its models' textures to GPU block formats
(BC7, BC5, BC4) as it loads them, on the HD renderer, and keeps each cook in the player's cook cache
(`textures/cook-v<N>` in the platform cache folder). Without help the first load on a player's
machine pays for every cook (seconds on a large scene). So the editor's export of an **HD** project
with the setting on cooks them first and ships them:

| Path | What it is |
|---|---|
| `app/texture-cook/<name>.ktx2` | one per distinct texture a load would cook: every glTF/GLB the export packs, each texture by the role its materials give it (an alpha-tested base colour at its cutoff, a normal map, ORM data). `<name>` is the cook cache's own name for it (a hash of the pixels, the size, the usage, the quality and the cook version), so a load asks for it by the key it would look up in its cache |

- **A load reads it first.** The shipped game looks in `<project root>/texture-cook/` before the
  player's cache; a texture found there is not cooked and not copied into the cache. A file that is
  not there (another engine's cook version, other pixels) or not the shape its usage cooks to falls
  back to the player's cache and the cook, as without it; the shipped files are never written.
- **Off (`"compressTextures": false`) or a standard project:** nothing is cooked or packed, and the export is byte for byte
  what it was. A project file under a root `texture-cook/` folder is refused by name when the export
  cooks (`project-invalid`).
- **Deterministic:** cooked fresh on every export from the revision's snapshot (never from the
  machine's own cache), with an encoder whose bytes do not depend on the thread count; the files go
  in name order. The same revision exports the same bytes.
- **Size:** the export grows by the cooked files; the models keep their own images (a renderer
  without block textures, or with virtual textures on, still uploads those). About 1.33 bytes per
  texel for BC7 and BC5 and 0.67 for BC4, mips included: a 2048 x 2048 colour map adds 5.6 MB.
- **Export time:** the cook runs in the export (tens to hundreds of milliseconds a texture).
- Only the editor's export (and the agent's, which is the same code) cooks today; `engine export
  --target native` does not, and its games cook on the first load as before.

## Known limits

- The running window's title and icon, and the taskbar entry, do not show the application until
  project launch (S3); see [the surfaces](#the-surfaces-a-player-sees).
- `window.resizable` is not applied.
- There is no publisher field, so `CompanyName` and `LegalCopyright` are empty.
- Executables are not signed; Windows SmartScreen may warn on first run.
- A windowed launch of an export is not part of the automated gates, because it opens a window.

