Talos docs

Native export — engine export

Since 0.6Changed in 0.7

Exporting a game as one executable.

Plain text

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
  2. Commands
  3. What the executable carries
  4. Two exporters, one format
  5. Manifest fields and precedence
  6. Release and development exports
  7. Generated icon outputs
  8. Windows version information
  9. The surfaces a player sees
  10. Running an export
  11. Output paths
  12. The launcher cache
  13. Target support and export-time requirements (Two engine builds, Signing, Linux packages)
  14. When a change takes effect
  15. Errors
  16. Checking an export
  17. From the editor
  18. The web target
  19. Third-party notices
  20. The shader pack and pre-warm lists
  21. Textures cooked ahead of time
  22. Known limits

Quick start

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

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

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]
LocationExportOutput nameApplication record
a project folder or argus.project.jsonevery scene, booting into its startup scene, brandedapplication.executableNameyes
any other file (a scene)that scene, legacy defaultsthe scene's meta.name, with characters outside A-Z a-z 0-9 _ - replaced by -no (branding: null)
FlagMeaning
--target nativeone executable for the host (or --launcher-target)
--target weba scene or a project: a static folder, <out>/web/ (default dist/web/), for any static host (The web target)
--target macosa 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 iosa project: the Xcode project's inputs in <out>/<name>-ios/. It is not an app until a Mac runs xcodegen and xcodebuild
--web-runtime dirweb: 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 dirthe folder the executable goes in; without it, a new dist/<job id>/
--developmentvalidate a project for development: a missing icon uses the built-in one
--name xthe output file name, overriding the executable name or scene name
--telemetry URLa 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 criticembed the workspace's default headless engine instead of the deploy engine
--no-buildnever run cargo; fail when an engine or launcher is missing
--launcher-targetthe 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)
--package tar|appimagealso write a Linux tarball and/or AppImage beside the executable (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).

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 pathWhat
bin/argus or bin/argus.exethe engine: the deploy physics build with a window and gamepads (--no-default-features --features deploy,window,gamepad)
app/scene.jsona single-scene export's scene, asset paths rewritten to assets/<hash>.<ext>
app/<project path>.scene.jsonevery 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.jsonproject 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 fileengine 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)
gameplay/entries.json, gameplay/modules/*.jsthe behaviors of every packed scene bundled to JavaScript, and the built-in modules they name
app/argus.project.jsonproject exports: the manifest in canonical form, with startupScene rebased to the packed startup scene's path and application.icon to icon.png
app/icon.pngproject exports: the icon file exactly as authored (or the built-in icon)
app/branding.jsonproject exports: the application record
app/ui/**project exports: the whole ui/ tree as authored — screens, styles, theme, locales, components, icons, scenarios
app/ui/screens.jsonproject exports: every screen already described, so a machine with no esbuild can register them
app/ui/fonts/*.ttfproject 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.jsona 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.txtthe licences of the third-party code and fonts in the engine, the launcher and what an export may pack (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.stringifyd, 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):

FieldFrom
projectId, applicationId, displayName, executableName, version, buildNumberthe manifest (buildNumber defaults to 0)
prereleaseversion has a prerelease part
purposerelease or development
manifestHashprojectManifestHash of the manifest as authored
iconsource (project or builtin), path, sha256, width, height
windowwidth, height and resizable, defaults applied
windowsWindows targets: the version numbers and strings written, the icon sizes, the .res hash; null elsewhere
surfaceswhat each surface shows, and why
revisionId, artifactId, buildIdthe 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)
Runsthe CLI, CI, a developer's machinethe installed editor, and argus-export
Builds the engine and launcherwith cargo, unless both are givennever; both are inputs
Transpiles behavioursesbuild, in processnever; already-JavaScript modules are inputs
Windows icon and versioncompiled to a .res and linked at build time (needs MSVC)patched into a prebuilt template's resource slot (needs nothing)
Can be cancellednoyes, 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 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.

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

Setting1. Export option2. Project3. Legacy default (scene export)
Output file name--nameapplication.executableNamescene meta.name
Scene the game boots into—startupScenethe scene given
Scenes packed—every *.scene.json in the projectthe scene given, alone
Executable icon (Windows)—application.icon; development export without one: built-innone
Version resource (Windows)—application.version, application.buildNumbernone
Window size—window.width × window.height, passed as --window-sizethe engine's 1280 × 720
Window title—not applied yet: Talos — scene.jsonTalos — scene.json
Window icon—not applied yetnone
Resizable—not applied yetthe 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)FormatWhy
16, 24, 32, 4832-bit BGRA DIB with an AND maskthe classic sizes
20, 40, 6432-bit BGRA DIB with an AND mask125 %, 250 % and 400 % display scaling
256PNGlarge 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):

FieldValue
FILEVERSION, PRODUCTVERSION (numeric)MAJOR, MINOR, PATCH, buildNumber (windowsVersionInfo), each re-checked against 16 bits and never truncated
File flagsVS_FF_PRERELEASE when the version has a prerelease part
FileVersionMAJOR.MINOR.PATCH.buildNumber
ProductVersionapplication.version verbatim, prerelease included
FileDescription, ProductNameapplication.displayName
InternalNameapplication.executableName
OriginalFilenameapplication.executableName + .exe
Comments<application.id> (project <projectId>)
CompanyName, LegalCopyrightnot 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:

SurfaceWindowsLinuxWhere it comes from
Output file nameexecutableName.exeexecutableNamethe export
Explorer / file manager iconthe executable's group iconnone (no executable icon exists)the launcher's resources
Properties › Details versionthe version resource abovenonethe launcher's resources
Running window titleTalos — scene.jsonsamethe embedded engine (legacy until S3)
Running window iconnone set by the enginenone set by the enginethe embedded engine (legacy until S3)
Taskbar entrythe engine process's window; that process (bin/argus.exe) carries no application resourcesthe engine's windowthe 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

<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). 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 → executableBrandingStatus
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 recordbuilt and tested (the Windows packaging smoke)
Windows → Windows GNU (*-windows-gnu)refused for a project export (target-unsupported): its linker does not take a .resnot supported
Linux → Windowsnot supported: no MSVC linker or Windows SDK on Linux, and the engine must be a Windows buildnot supported
macOSno 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
Weba static folder: page, wasm runtime, scenes, assets, behaviours as JavaScript, manifestbuilt 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.

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"; the Steam depot notes, section 2). 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:

BuildCargo featuresCLI (engine export) finds it atBeside the editorPacks
standarddeploy,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
HDdeploy,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:

ChangeIn an exported executable
displayName, executableName, version, buildNumber, icon, startupScene, windowre-export
the scene, its assets or behaviorsre-export
the engine or the launcher sourcesre-export (a new launcher key, a new payload id)
--headless, --offscreen, --port, --ws-port, --scale, --telemetry, --stuck-after, --playerthe 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:

CodeMeaning
usagecontradictory 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)
project-invalidthe project fails validation for the export's purpose; details holds the violations
icon-decodethe icon cannot be decoded, is too large, or is not square
version-rangea version part does not fit 16 bits
target-unsupporteda branded export for a Windows target that is not MSVC
binary-missingno engine or launcher, and building is off or ARGUS_DEPLOY_BIN names nothing
binary-mismatchthe 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)
size-gatethe 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-buildrustc or cargo failed
output-lockedanother export is writing the same executable
verify-failedthe composed file, or the bundled app/ project, did not read back as written
cancelledthe export was cancelled between stages or inside the asset loop; nothing was written (crates/export only)
resource-slota 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-failedthe --sign-cmd command failed, could not be started, or left no file (packages/export only)
package-failedappimagetool was found and failed (packages/export only); a missing tool is a NOT-BUILT package, not an error
engine-stalea 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 editorWhat it isInstalled by
argus-deploy-engine.exethe deploy,window,gamepad engineinstall-editor.ps1 -Deploy <exe>
argus-deploy-engine-hd.exethe deploy,window,gamepad,hd engine, for HD projects; optionalinstall-editor.ps1 -DeployHd <exe>
argus-launcher.exethe prebuilt launcher template, with the fixed resource slotinstall-editor.ps1 -Launcher <exe>
<name>.sha256the hash the installer recorded, sha256sum formwritten 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), 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 folderWhat
index.htmlthe 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>.wasmthe 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.jsonbehaviours, already JavaScript, run by the page's own JavaScript engine (see "Behaviours on the web" below)
README-hosting.txtfor whoever hosts it: serve over http(s), not file://; WebGPU; the CSP 'unsafe-eval' behaviours need; no COOP/COEP; .wasm as application/wasm
manifest.jsonevery 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) 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.

PathWhat
shader-pack/<hash>.family.jsonone 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.jsonhow 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>.jsonone 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>.jsonone 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:

PathWhat it is
app/texture-cook/<name>.ktx2one 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.
  • 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.