UI capture: the store view
UI capture: the store view
Section titled “UI capture: the store view”tools/ui_capture renders the real game at a device’s pixel size and saves PNGs of the screens a player sees.
Until 2026-10-22 it answered a different question from the one a layout reviewer asks. It ran inside the Godot
editor, where BuildConfig.dev_tools() is true, so it drew the Dev UI (REMOTE row, Debug Settings, the learning
tag), and it booted main once under the Mac’s own insets and only then switched Platform.force_* per device
family, so anything that places itself once in _ready landed in the wrong spot. Its PNGs also said nothing about
which build or device they came from.
Store mode fixes the three: it captures what ships, boots every family under that family’s own device state, and stamps every PNG with a sidecar. What it still cannot show is listed at the end; read that list before you call a screen “checked”.
Run it
Section titled “Run it”bash scripts/dev/ui-capture-store.sh --profile iphone --out /abs/dir [--scenes menu,hud,pause] [--dev]| Argument | Meaning |
|---|---|
--profile |
iphone, iphone_se, ipad or tv (required) |
--out |
Directory for everything the run writes. A relative path resolves from where you ran the script |
--scenes |
Comma list of tools/ui_capture scene ids. Default menu,hud,pause: short on purpose, the window lock is machine-wide |
--dev |
Capture the Dev view instead (no store switch). The PNGs land in <out>/dev/ and the result is warn |
The script snapshots project.godot and audio/bus_layout.tres, runs a headless --import (a fresh worktree or a
new class_name needs the class cache), then takes the machine-wide window lock (scripts/dev/godot-window-lock.sh)
for a windowed Godot. Both Godot runs sit under perl -e 'alarm N' (macOS has no timeout). The two files are put
back afterwards, so a developer’s own uncommitted edit to either survives. A run takes about 11 s end to end for one
profile with the default scenes (six frames, the warm import included); a cold worktree adds minutes for its first
import. The script takes one profile. Driving tools/ui_capture/capture.tscn yourself with several ids in
UI_CAP_SIZES captures them in one process, about 25 s for all four.
| Exit | Meaning |
|---|---|
| 0 | pass or warn: the PNGs were written |
| 1 | fail: the run did not produce what was asked, or Godot hung or died. result.json says which |
| 2 | Bad usage. The tool was not run |
| 3 | inconclusive: another windowed Godot held the window lock for the whole wait. Nothing was captured; re-run |
Godot’s own exit code is never trusted (a teardown abort, 134, follows green runs): the result.json it wrote decides.
If Godot never got that far the script writes the fail or inconclusive result itself, so <out>/result.json
always exists.
Before anything else the script deletes the old result.json and this profile’s frames (*_<profile>.png and their
.png.json sidecars) from the one folder the run writes to: <out>/store/, or <out>/dev/ with --dev. Nothing from
an earlier run can pass for this one: a glob over --out after a run that saved only three of four scenes would
otherwise return last week’s fourth beside today’s three, and pass would not say so. It is a find on those two
patterns, never rm -rf, so another profile’s frames, the other view’s folder and any file of yours in --out stay.
It runs before the import, so a run that dies early leaves no old frame behind either.
Driving tools/ui_capture/capture.tscn directly (windowed only; headless aborts with a fail result) takes these
environment variables. The script sets all of them for you.
| Variable | Effect |
|---|---|
UI_CAP_STORE=1 |
The store view. Unset = the Dev view |
UI_CAP_SIZES |
iphone,iphone_se,ipad,tv (unset = iphone,ipad). An unknown id aborts the run instead of silently making no frames |
UI_CAP_SCENES |
Scene ids to capture (unset = every default scene). A requested id the run never reached fails the result |
UI_CAP_OUT |
Output directory (default user://ui_capture) |
UI_CAP_RESULT |
Path for the fidelity-result/1 JSON |
UI_CAP_TEXT_SIZE |
0 Default, 1 Large, 2 Larger |
UI_CAP_PAD |
xbox, ps or switch: the glyph set for the stats_full scene’s hint rows, instead of the family’s own (the TV’s Xbox set, none on the touch families) |
UI_CAP_GIT_REV, UI_CAP_GIT_DIRTY |
The revision stamped on every sidecar (0 or 1 for dirty). Unset = asked of git |
How the pixels become the device’s
Section titled “How the pixels become the device’s”-
The store switch goes in before
mainexists.UI_CAP_STORE=1setsBuildConfig.force_store, which makesdev_tools()answer false whatever the editor says. It has to come first becauseDevFlags.boot()readsdev_tools()once inmain._readyandBuildChannelcaches its first answer. The run aborts ifdev_tools()is still true afterwards. Nothing in the game setsforce_store; a release export never sees it. -
Each family gets a fresh
main, built under that family’s own state. The tool’s_boot_familyis two shared functions in a fixed order:retire_mainfrees the previous family’smain:queue_free, wait fortree_exited, then 3 frames so its deferred frees have run. The first family has nothing to retire.boot_main_intocallsapply_family(Platform.force_touch / force_screen_scale / force_safe_insets_pt, thenTokens.ensure_loaded(), thenLiveTokens.set_platform(...), thenTokenBind.flush_now(), then the controller glyph seam in step 3), sizes the viewport (size_viewport), instantiates and adds a newmain, and lets it settle for 10 frames.
The family’s state is in force before the new
mainexists, and that is the point:WeaponPanelplaces itself once, in_ready, from the safe insets of that moment.Tokens.ensure_loaded()has to come beforeset_platform: it re-picks the platform variant from the OS on the first token read after every load, andmain._readymakes that read, so a TV platform set earlier comes back as the Mac’s. -
Controller glyphs follow the device. A touch family draws no glyphs (the prompts resolve to
noneby themselves). An Apple TV player always holds a pad, because the Siri Remote cannot play the game, but the Mac running the capture has a mouse and no pad, soButtonPromptresolved to keyboard glyphs and the TV frames said “WASD Move to dodge”.apply_familynow setsButtonPrompt.force_style = "xbox"for thetvfamily (the glyph settools/marketing/shoot.gduses; a PlayStation or MFi pad changes shapes and hues, not the layout) and leaves it unset for the others. The sidecar records whatButtonPrompt.current_style()answered asbutton_style. Two scenes override the style for their own purpose (bay_notready_pad, andstats_fullwithUI_CAP_PADset) and put the family’s value back afterwards. -
The state is asserted, not assumed, at two points.
- After boot, the tool checks the live insets, screen scale, touch flag, token platform, viewport size and
dev_tools()against the family’s device table, and where the weapon dock and the drone dock actually sit against the positions that table implies (DOCK_ORIGINplus the family’s insets, computed from the table and the viewport’s geometry, not read back fromPlatform). The dock check is what catches a wrong boot order: the statics are whateverapply_familywrote whenever it ran, so they cannot tell amainbuilt before it from one built after.WeaponPanelis the canary because it never re-places itself. A panel that places itself once and is not one of the two docks is not covered. Any sentence fails the run before a frame is saved, naming what moved. - Before every PNG, the statics only. A scene body may legitimately move a panel, so the docks are checked once, right after boot at the family’s Default text size, not per shot.
tests/test_ui_capture_boot.gdboots a realmainthrough the sameretire_mainandboot_main_into, so movingapply_familybelow theadd_childthere turns it red. A test that boots its own copy of the order proves the copy, not the tool. A source-text pin ties_boot_familyitself to those two functions and toboot_problems. - After boot, the tool checks the live insets, screen scale, touch flag, token platform, viewport size and
The capture’s save is a fixture, not a fresh install: 1840 gold banked, tutorial done, every enemy type marked seen,
shop tips spent. Writes to disk are off (MetaStore.disk_io_enabled = false), so a capture never touches a real save.
What the store view removes
Section titled “What the store view removes”Every one of these is a BuildConfig.dev_tools() gate that answers false under force_store:
- Start menu: the REMOTE row. That row also makes the Dev menu taller, so it compacts further to fit a short screen than a player’s does, and at the last fit step (6) on a touch device the Dev build hides the title as well.
- Pause menu: the Arm Remote Control and Debug Settings buttons, and the pairing-code label.
- HUD: the LEARNING RUN tag.
- The learning-autopilot run itself. In a Dev capture the Survival run was a learning run (
DevFlags.learning_autopilotdefaults on whendev_flags.jsonhas no such key), which forces HARD difficulty; a store player’s default is NORMAL. So the Dev capture also played a harder game, not only a different menu. - The machine’s
DevFlags(user://dev_flags.json: candidate weapon looks, hard tuning, dynamic zoom, the Tackler, Blight and web-drone toggles). They load only for a Dev build, so the store view ignores whatever the developer left on in Debug Settings.
BuildChannel reads appstore under the store view: the fail-safe answer it gives when no store receipt is found,
which is what an App Store player reads (a TestFlight tester reads testflight).
What a run writes
Section titled “What a run writes”<out>/store/<scene>_<family>.png the frame (<out>/dev/ with --dev)<out>/store/<scene>_<family>.png.json its sidecar<out>/result.json the verdict (fidelity-result/1)<out>/godot.log, <out>/import.logSidecar (ui-capture-sidecar/1)
Section titled “Sidecar (ui-capture-sidecar/1)”| Field | Meaning |
|---|---|
family, scene, file |
Which device, which scene, the PNG’s own file name |
px |
PNG size in pixels [w, h] |
screen_scale |
Device pixels per point the run forced (3.0 iPhone, 2.0 iPad and SE, 1.0 TV) |
insets_pt |
Safe-area insets in points [left, top, right, bottom], read from Platform at the moment of the shot |
touch |
Whether the touch UI was on (false for TV) |
token_platform |
The LiveTokens variant: touch, tv or empty |
button_style |
The glyph set the frame drew, from ButtonPrompt.current_style() at the moment of the shot: none on the touch families, xbox on tv, the UI_CAP_PAD value on a stats_full frame that set one. key would mean keyboard glyphs (what the TV frames drew before the seam) |
store |
true = dev_tools() was false, the view a store player gets. false = the Dev UI |
text_size_step |
TextSize.step (0 Default, 1 Large, 2 Larger) |
build |
Sim_Const.BUILD of the tree that drew it |
git_rev, dirty |
Short revision, and whether tracked files differed from it. Untracked files are ignored (Godot drops .uid files everywhere). A revision that cannot be read reads dirty |
black |
The frame was wholly black (see below) |
captured_utc |
When |
All of it is read from the live statics at capture time, never copied from the device table. An example
(compacted; the file has one value per line) from an iPhone run on 2026-10-22, at the commit that added button_style
(51402d2b), on a clean tree:
{ "black": false, "build": 336, "button_style": "none", "captured_utc": "2026-10-22T04:15:02", "dirty": false, "family": "iphone", "file": "hud_tactical_iphone.png", "git_rev": "51402d2b0f", "insets_pt": [62.0, 0.0, 62.0, 21.0], "px": [2868, 1320], "scene": "hud_tactical", "schema": "ui-capture-sidecar/1", "screen_scale": 3.0, "store": true, "text_size_step": 0, "token_platform": "touch", "touch": true}Result (fidelity-result/1)
Section titled “Result (fidelity-result/1)”| Status | When |
|---|---|
pass |
Every scene a family selected saved at least one frame for that family, each under that family’s device state, in the store view |
warn |
Frames were saved but are not the shipped view (--dev), or a frame was wholly black (a backgrounded window renders black) |
fail |
An unknown family, a frame that did not save, a family that made no frame, a requested scene no family selected, a selected scene that saved no frame for some family, device-state drift or a misplaced dock after boot, or an abort |
inconclusive |
Written by the script when the window lock never freed. No verdict on the UI |
headline is at most 140 characters. metrics carries frames, frames_failed, black_frames, families,
scenes_entered, store, build, git_rev, dirty, elapsed_s and problems. artifacts lists each PNG and its
sidecar relative to the result file. profile is the family id when one family was captured, global otherwise.
pass says the frames were saved under the device’s state. It does not say the layout is right.
scenes_entered is the union of the scene ids the families selected, not of the frames that came out of them: a
scene body that is entered and then saves nothing used to read as covered. Every frame record now carries the scene
id it was shot for, because the frame name does not follow from the id (baynotready saves bay_notready,
levelup_boss saves hud_boss, nudge saves drone_nudge). A family that made frames but none for a scene it
selected fails the run with scene <id> saved no frame for <family>. A family that made no frame at all is
reported once, as <family> made no frame.
The registry entry for the device-simulation runner is tools/device_sim/registry.d/ui-capture-store.json: tier
window, scope profile, all four profiles, timeout_s 600. That is the sum of the script’s budgets (import 180 s,
window-lock wait 240 s, run 120 s) plus 60 s of slack; a normal run takes about 11 s (see Run it).
tests/test_ui_capture_store.gd pins the entry’s shape and that its --scenes default equals the script’s.
Before and after
Section titled “Before and after”Where the weapon dock (the HUD’s left-hand weapon tiles) and the drone dock sat, in UI units, measured by booting a
real main both ways. The old order built main under the Mac’s own state and applied the family afterwards; the
drone dock re-places itself on a token change, the weapon dock does not.
| Family | Old: weapon dock | Old: drone dock | New: weapon dock | New: drone dock |
|---|---|---|---|---|
| iphone | (24.0, 109.7) | (102.0, 162.0) | (102.0, 82.0) | (102.0, 162.0) |
| iphone_se | (24.0, 130.7) | (24.0, 162.0) | (24.0, 82.0) | (24.0, 162.0) |
| ipad | (24.0, 105.6) | (24.0, 162.0) | (24.0, 82.0) | (24.0, 162.0) |
| tv | (24.0, 115.9) | (88.0, 198.0) | (88.0, 118.0) | (88.0, 198.0) |
Old: on the iPhone and the TV the two docks did not share a left edge (78 and 64 units apart), and on every family
the weapon dock took the host display’s safe area (the Mac’s menu bar) as its top inset: 27.7, 48.7 and 23.6 units
too low on iPhone, SE and iPad, and 2.1 too high on the TV (its title-safe band is 36 units). New: both docks sit at
their DOCK_ORIGIN past the family’s own insets.
How the tests stand behind that: tests/test_ui_capture_boot.gd boots a real main per family through the tool’s
own retire_main and boot_main_into and asserts dock_problems is empty, the same function the run calls after
boot, with the expected positions computed from the device table. Two more tests take a correctly booted main,
hand-misplace a dock (the weapon dock back to x=24, the drone dock 40 units down) and assert the checker names
exactly that dock, so the check is shown able to fail. The boot order itself was mutation-checked when this was
written: with apply_family moved below add_child inside boot_main_into, 4 of the file’s 9 tests go red, with
messages such as “weapon dock sits at (24.0, 82.0), the iphone device puts it at (102.0188, 82.0)”. The check
covers the two docks only; another panel that places itself once in _ready is not looked at.
What a store capture still cannot show
Section titled “What a store capture still cannot show”A capture is the real game on a Mac. These differences from a device survive store mode:
- No launch sequence.
PrewarmPassandBootSplashare off in the editor, so the launch cover and the PSO prewarm never run. First-launch stalls needbh-fresh-install-stall-checkon a device. - No live services.
RemoteConfigandEventQueueare not live in the editor. A capture shows the bundled tunables, bible and notice state, never the patch or feature flags a store player is on. In particular the CO-OP ONLINE start-menu row (OnlineCoop.enabled: Dev, or the remoteinternet_coopflag) is absent from the store view even where a shipped build has the flag on. - Host-keyed UI follows the Mac.
Platform.has_mouse()has no override and reads true on a Mac. The controller glyphs no longer follow it for the Apple TV: thetvfamily forces the Xbox set throughButtonPrompt.force_style(a TV player always holds a pad), and every sidecar’sbutton_stylesays what was drawn. Everything else keyed onhas_mouse()still answers for the Mac: the Settings step buttons, the tutorial skip chip’s tap affordance and mouse aim. Settings rows keyed onOS.get_name()answer for macOS (the Display row, Fullscreen or Windowed, exists only there, so a Settings capture shows a row no iPhone, iPad or Apple TV has). The glyph set is one pad style, not the player’s own: a PlayStation or MFi pad changes shapes and hues, not the layout. The tool never setsPlatform.force_tv, soPlatform.is_tv()is false for thetvfamily (only the codex overlay’s hint text and the LAN lobby read it); the family’s insets, scale and token platform are the TV’s. - SDR only, no baked shaders, no HDR/P3 output. Glow, shader and colour fidelity are not checked, and the headless gate never compiles shaders. Judge those on a device.
- Text size applies after boot.
UI_CAP_TEXT_SIZErunsTextSize.applyoncemainis built, so anything that lays itself out once at boot was laid out at Default whatever size was asked. Fit-test those (TextSizeFixture); a seam inmain.gdwould let the size apply before boot. - Black-frame check is coarse. It samples a 64 by 36 grid and flags only a wholly black frame. A partly blank frame still passes.
dirtyignores untracked files, and an unreadable revision is reported as dirty rather than clean.
Changing it
Section titled “Changing it”- A new
dev_tools()gate that changes what a player sees needs no change here: store mode already answers false for it. - A new device family goes in
DEVICEandSIZESincapture.gd(andtoken_platform_forwhen it is not a touch device), in the script’s profile list and in the registry’sprofiles.tests/shop_layout_fixture.gdcarries the same numbers for iphone, ipad and tv; a test fails if the two drift. BASE,DEVICEandSIZESkeep their names and one-row-per-family source shape: the device-simulation work’s drift lint reads them as source text, and a test pins the shape.- Tests:
tests/test_build_config.gd,tests/test_ui_capture_store.gd,tests/test_ui_capture_boot.gdandscripts/test/ui-capture-store.test.mjs(the script, driven against a fake Godot).