Skip to content

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”.

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
  1. The store switch goes in before main exists. UI_CAP_STORE=1 sets BuildConfig.force_store, which makes dev_tools() answer false whatever the editor says. It has to come first because DevFlags.boot() reads dev_tools() once in main._ready and BuildChannel caches its first answer. The run aborts if dev_tools() is still true afterwards. Nothing in the game sets force_store; a release export never sees it.

  2. Each family gets a fresh main, built under that family’s own state. The tool’s _boot_family is two shared functions in a fixed order:

    1. retire_main frees the previous family’s main: queue_free, wait for tree_exited, then 3 frames so its deferred frees have run. The first family has nothing to retire.
    2. boot_main_into calls apply_family (Platform.force_touch / force_screen_scale / force_safe_insets_pt, then Tokens.ensure_loaded(), then LiveTokens.set_platform(...), then TokenBind.flush_now(), then the controller glyph seam in step 3), sizes the viewport (size_viewport), instantiates and adds a new main, and lets it settle for 10 frames.

    The family’s state is in force before the new main exists, and that is the point: WeaponPanel places itself once, in _ready, from the safe insets of that moment. Tokens.ensure_loaded() has to come before set_platform: it re-picks the platform variant from the OS on the first token read after every load, and main._ready makes that read, so a TV platform set earlier comes back as the Mac’s.

  3. Controller glyphs follow the device. A touch family draws no glyphs (the prompts resolve to none by 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, so ButtonPrompt resolved to keyboard glyphs and the TV frames said “WASD Move to dodge”. apply_family now sets ButtonPrompt.force_style = "xbox" for the tv family (the glyph set tools/marketing/shoot.gd uses; a PlayStation or MFi pad changes shapes and hues, not the layout) and leaves it unset for the others. The sidecar records what ButtonPrompt.current_style() answered as button_style. Two scenes override the style for their own purpose (bay_notready_pad, and stats_full with UI_CAP_PAD set) and put the family’s value back afterwards.

  4. 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_ORIGIN plus the family’s insets, computed from the table and the viewport’s geometry, not read back from Platform). The dock check is what catches a wrong boot order: the statics are whatever apply_family wrote whenever it ran, so they cannot tell a main built before it from one built after. WeaponPanel is 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.gd boots a real main through the same retire_main and boot_main_into, so moving apply_family below the add_child there 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_family itself to those two functions and to boot_problems.

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.

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_autopilot defaults on when dev_flags.json has 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).

<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.log
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
}
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.

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.

A capture is the real game on a Mac. These differences from a device survive store mode:

  • No launch sequence. PrewarmPass and BootSplash are off in the editor, so the launch cover and the PSO prewarm never run. First-launch stalls need bh-fresh-install-stall-check on a device.
  • No live services. RemoteConfig and EventQueue are 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 remote internet_coop flag) 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: the tv family forces the Xbox set through ButtonPrompt.force_style (a TV player always holds a pad), and every sidecar’s button_style says what was drawn. Everything else keyed on has_mouse() still answers for the Mac: the Settings step buttons, the tutorial skip chip’s tap affordance and mouse aim. Settings rows keyed on OS.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 sets Platform.force_tv, so Platform.is_tv() is false for the tv family (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_SIZE runs TextSize.apply once main is 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 in main.gd would 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.
  • dirty ignores untracked files, and an unreadable revision is reported as dirty rather than clean.
  • 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 DEVICE and SIZES in capture.gd (and token_platform_for when it is not a touch device), in the script’s profile list and in the registry’s profiles. tests/shop_layout_fixture.gd carries the same numbers for iphone, ipad and tv; a test fails if the two drift.
  • BASE, DEVICE and SIZES keep 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.gd and scripts/test/ui-capture-store.test.mjs (the script, driven against a fake Godot).