Fidelity: release-VM smoke
Fidelity: release-VM smoke
Section titled “Fidelity: release-VM smoke”scripts/release-smoke.sh boots the exact candidate under the macOS release export template, the GDScript VM that
devices run, and takes the device-only auto-arm path with the network off. It answers one question: does this commit
launch on a release build, or does it crash before the first frame?
Why it exists
Section titled “Why it exists”scripts/gate.sh boots the game twice (the “boot smoke” and the “device-boot smoke” steps), both in the editor’s debug
VM. The two VMs disagree about errors. The debug VM reports a bad call as a soft SCRIPT ERROR and carries on. The release
VM compiles those checks out, so the same call dereferences null and the process dies with a segfault.
On 2026-10-09 the boot /status read debug_settings before it existed. The debug VM logged a SCRIPT ERROR and kept
going; the release VM segfaulted at launch on Apple TV and iPhone. The gate has since gained the device-boot step, which
takes the same device-only boot path and fails on a SCRIPT ERROR in the debug VM’s log, so it catches that exact line
today. What it cannot see is anything that behaves differently in the release VM or in an exported pck (a different
file set from the source tree). This smoke is that second opinion, run on the pck itself.
What it covers, and what it does not
Section titled “What it covers, and what it does not”Covers:
- The commit’s scripts and scenes, exported with the tracked
platform/tvospresets, running under the stock macOS release template: a crash, a hang, a script parse error, or (Store pass) the boot code failing to reach its first save file. - The device auto-arm boot path (
ControlClientarmed,/capsand/statusbuilt, a poll queued) in the release VM, with the network off: the markerDEVICE_BOOT_SMOKE armed=true captured=N http_requests=0is required. - Files the export filter strips (
tests/*,tools/*,telemetry/*and so on). A boot path thatpreloads orload()s one works from source and is missing from the pck.
Does not cover (the verdict says so in its caveat):
- The device GPU and Metal. The run is headless, so nothing is rendered. A shader, driver or Metal fault is invisible.
- The Godot 4.8 P3 fork that exports the iOS and tvOS candidates. This is the stock 4.7.1 release VM, not the fork’s. A
fork-only crash (the
GPUFamilyApple6gate that rejected build 18 is the precedent) is out of reach. Fork fidelity still needs a device run. - Native iOS and tvOS plugins (
DcHaptics, Sentry), on-device state, memory limits, and anything a macOS host cannot reproduce. - Soft script errors in the release VM. They are compiled out, so only crashes, hangs and load errors show. Keep the gate’s
debug-VM
SCRIPT ERRORcheck; this smoke does not replace it. - A windowed run. See “Why there is no –windowed” below.
- Anything the boot does not reach: a defect behind a menu, a run or a boss fight is invisible to it.
Run it
Section titled “Run it”scripts/release-smoke.sh # both passes, about 25 s on a warm machinescripts/release-smoke.sh --dev # Dev pass onlyscripts/release-smoke.sh --store # Store pass onlyscripts/release-smoke.sh --pck FILE [--store] # smoke a pck you already have, no exportscripts/release-smoke.sh --secs 60 --keep # longer guard; keep the scratch dir and print its pathscripts/release-smoke.sh --result out.json # also write a fidelity-result/1 JSON (run logs are copied beside it)scripts/release-smoke.sh --classify LOG EXIT # classify a finished run, pure: no Godot, no exportRun it before a store or TestFlight submission, and after touching boot code (main._ready, autoloads, ControlClient,
BuildConfig, MetaStore). It is not wired into scripts/release/release.sh yet: that is a release-process change
and needs Chris’s OK. The natural place is just before the build step, as a hard stop on CRASHED, HUNG,
SCRIPT-ERROR and MARKER-MISSING.
The device-sim harness picks it up from tools/device_sim/registry.d/release-smoke.json (headless tier, global scope,
needs godot and macos-template). One mismatch to know about: the harness’s macos-template check also wants the
gitignored root export_presets.cfg, which this script does not use (it exports from the tracked platform/tvos presets), so
in a fresh worktree the harness can report SKIP for a tool that runs fine by hand.
What a run does
Section titled “What a run does”- Finds the editor (
godot, orBH_RELEASE_SMOKE_GODOT) and the matching template (below). It prints both versions. - For each pass, exports a pck (cold,
BH_EXPORT_COLD=1) from the trackedplatform/tvos/export_presets.cfg, throughgodot_export packso stale caches and rewritten*.importfiles are handled:- Dev pass: preset
iOS-Dev(custom featuresdev,dev_filelog), run with-- --device-boot-smoke. - Store pass: preset
macOS(nodevfeature, whatscripts/release/build-macos.shships), run plainly.
- Dev pass: preset
- Copies the template’s release binary to a distinct name,
bh-release-smoke, with the pck beside it asbh-release-smoke.pck. A release binary refuses--main-packand loads<binary>.pckfrom next to itself. The distinct name means a crash report isbh-release-smoke-<date>.ips, so it cannot be mistaken for the editor’s. - Runs it headless with
--quit-after 600under a wall-clock guard (--secs, default 60; a healthy boot takes about 5 s), withHOMEset to a scratch directory, one per pass. - Restores
project.godotandaudio/bus_layout.tresif the Godot run rewrote them (and says so), and removes the scratch directory unless--keep.
The root export_presets.cfg is gitignored and absent from a fresh worktree, which is why the tracked
platform/tvos presets are used.
Reading the verdict
Section titled “Reading the verdict”The last line of stdout is the verdict. Exit codes:
| Verdict | Exit | Meaning |
|---|---|---|
PASS |
0 | Every pass booted. The Dev pass printed the marker; the Store pass wrote its own boot file. |
CRASHED |
1 | The process died on a signal (exit 128 or more, e.g. 139 = SIGSEGV). Decided from the exit status, never the log. |
HUNG |
1 | The wall-clock guard killed it (guard exit 124, or a raw SIGALRM 142). Never reported as a crash. A heavily loaded machine can be slow: re-run with a longer --secs before calling it a hang. |
SCRIPT-ERROR |
1 | The log has SCRIPT ERROR or Parse Error. Parse errors are not compiled out, and the process still exits 0. |
MARKER-MISSING |
1 | Exit was clean but the proof of boot is absent (see below). |
SKIP |
2 | No macOS release template for this Godot. Nothing was checked; this is not a pass. |
INCONCLUSIVE |
2 | Version skew, an export that failed, or a pck from another engine. Nothing was checked. |
| (usage) | 64 | Bad arguments. |
Exit 2 is deliberately not 0: a missing template must never read as a pass. Every failing pass is reported; the verdict is the first failing pass’s.
For the device-sim runner the mapping is: PASS is pass; CRASHED, HUNG, SCRIPT-ERROR, MARKER-MISSING are fail;
SKIP is skip; INCONCLUSIVE is inconclusive. The result JSON carries export_s, run_s, pck_mb, exit_code and
captured for the Dev pass and the same names with a store_ prefix for the Store pass (no captured: it prints no
marker), and its caveat restates what is not covered.
Why the proof differs per pass. The Dev pass must print DEVICE_BOOT_SMOKE armed=true captured=<n> http_requests=0 with n at least 1.
A release binary block-buffers stdout when it is redirected, so the marker (and the banner) appear only after a normal
exit: a segfault loses them. That is why a crash is read from the exit status. A store export ignores the flag by design,
so it prints nothing. A pck whose main script fails to parse still exits 0, so exit 0 alone proves nothing. The Store pass
therefore also requires that the game’s own boot code wrote a file (canary.txt or meta.json, from MetaStore in
main._ready) under its scratch user directory. No file means MARKER-MISSING.
Bare ERROR: and WARNING: lines never fail a run. The macOS template has no dc_haptics GDExtension, so every run logs
a few Can't open GDExtension dynamic library errors that are expected. They are hidden from the failure tail; the full
log is kept with --keep or --result.
Engine pairing
Section titled “Engine pairing”A pck built by one engine and run by another looks exactly like a crash, so the script pairs them explicitly:
- The template is found by the exact
godot --versionstring:~/Library/Application Support/Godot/export_templates/ <version>/macos.zip, looked up under your real home (the child run gets a scratch one). The extracted binary’s--versionmust equal the editor’s, else exit 2 with both versions printed. - A pck’s header records the exporting engine’s version (
GDPC, pack format 4, then major, minor, patch). A--pckfrom a different engine than the template isINCONCLUSIVEand names both versions. --template ZIP(orBH_RELEASE_SMOKE_TEMPLATE) overrides the lookup.
To smoke the pck that build-macos.sh ships, point --pck at the .pck inside the built app’s Contents/Resources with
--store. The script copies the template binary next to it, so it never runs the signed, sandboxed app itself.
Why there is no –windowed
Section titled “Why there is no –windowed”A windowed run is a live platform. A fresh HOME mints an install id, and EventQueue and RemoteConfig then send
telemetry and fetch remote config. The smoke flag silences only the ControlClient. The option was dropped rather than shipped
unverified; --windowed exits 64 with this explanation. A headless run reaches every boot path that matters here, and
EventQueue.push and the remote-config fetch are quiet headless.
The one code change: ControlClient.smoke_exported
Section titled “The one code change: ControlClient.smoke_exported”The flag --device-boot-smoke used to be inert in any export (exported = OS.has_feature("template")), so the release
VM could never be armed from a pck. net/control_client.gd now decides exported through a pure
smoke_exported(template, dev), which is template and not dev. A store export (template, no dev feature) still
ignores the flag. A Dev-feature export (the iOS-Dev, tvOS-Dev and Android-Dev presets) honours it. Honouring it is safe because
the smoke switches ControlClient I/O off before it arms (prepare_device_boot_smoke), so a Dev export handed the flag
cannot send a request. tests/test_device_boot_smoke.gd is unchanged; tests/test_device_boot_smoke_dev_export.gd pins the
new behaviour, including the call site’s wiring. main.gd still carries the older comment “never in an export”.
Measured: before the change, a Dev pck under the release VM printed no marker at all (nothing the smoke could check). After
it, DEVICE_BOOT_SMOKE armed=true captured=2 http_requests=0. And the store side holds: the real Store pck, handed
--device-boot-smoke (release-smoke.sh --pck store.pck with no --store), printed no marker and reported
MARKER-MISSING, so a store export stays inert under the release VM.
Proof: planted regressions
Section titled “Proof: planted regressions”Each defect below was planted in a scratch copy of the tree (never in the repo, never committed), the debug VM was run
the way gate.sh runs it, then scripts/release-smoke.sh was run on the same copy. Engine 4.7.1.stable, macOS 27.
| Planted defect | Debug VM (gate.sh) |
release-smoke.sh |
|---|---|---|
| none (the candidate) | green | PASS in 26 s: Dev marker seen, Store booted (exports 6.4 s and 6.7 s, runs 5.5 s and 5.4 s, pck 74.7 MB) |
2026-10-09: _dev_flag_states calls debug_settings.graphics_report(...) without its != null guard |
SCRIPT ERROR: Invalid call. Nonexistent function 'graphics_report' in base 'Nil' at main.gd:5677, exit 0, marker still printed. The gate’s SCRIPT ERROR grep fails it. |
CRASHED: Dev pass exit 139 (SIGSEGV) after 1.0 s, no marker. The crash report is EXC_BAD_ACCESS, KERN_INVALID_ADDRESS at 0x20, the null-member read of the original incident. |
A syntax error in main.gd |
SCRIPT ERROR: Parse Error at boot, exit 0. The gate’s boot step fails on that line. |
SCRIPT-ERROR in both passes: the release VM prints Parse Error and exits 0. |
Exported-pck only: a boot line preloads a script under tools/, which every store and dev preset’s exclude_filter strips |
green: no SCRIPT ERROR, marker printed, because the file exists in the source tree |
SCRIPT-ERROR in both passes, exit 0: Parse Error: Preload file "res://tools/vfx_library/library_manifest.gd" does not exist. The game never booted. |
Exported-pck only: a boot line load()s the same script by a runtime string |
green, same reason | CRASHED in both passes, exit 139 after 1.1 s and 1.0 s. The log says Attempt to open script 'res://tools/vfx_library/library_manifest.gd' resulted in error 'File not found'. |
Read this honestly. The 2026-10-09 plant is caught by both VMs: the debug VM prints the SCRIPT ERROR and today’s gate
already fails on it, so for that exact regression the release smoke adds a second, harder signal (a real segfault, with the
exit status) and nothing the gate would have missed. The class the gate cannot see is the last two rows: a defect that
exists only in the exported pck (its file set is the source tree minus the export filter) or only in the release VM.
That is what this smoke adds, in under a minute and before the build is uploaded. The two exported-pck rows are plants I
chose to show that class; they are not past incidents.
Two measured details worth knowing:
- A scene
ext_resourceon a stripped script did not reproduce the failure: the pck loaded fine, so scene dependencies are packed whatever the filter says. A GDScriptpreloador a runtime-built path is not followed. - A pck whose main script fails to parse exits 0. A smoke that looked only at the exit status would have passed the third and fourth rows, which is why the classifier also reads the log and the Store pass requires a boot file.
Related
Section titled “Related”docs/reference/running-and-testing.mdfor the gate and the harnesses.docs/reference/builds-and-releases.mdfor the release process this should join..claude/skills/bh-device-crash-reprofor symbolicating a crash report (parse_ips.py) and reproducing a device crash by hand; its step 3 can now bescripts/release-smoke.sh.