Skip to content

Bullet Heaven — dev tools, public site & web demo

Bullet Heaven — dev tools, public site & web demo

Section titled “Bullet Heaven — dev tools, public site & web demo”

Extracted from CLAUDE.md on 2026-07-04 to keep the always-loaded file lean. This is the current architecture reference for these systems, not a changelog — the “M2 cycle N, DONE” headings document present code. Keep it current when you change the code. See CLAUDE.md § “Subsystem architecture — read on demand”.

Remote Playtest Console (M2 cycle 23, DONE) — dev tool, off-by-default

Section titled “Remote Playtest Console (M2 cycle 23, DONE) — dev tool, off-by-default”

A web panel that controls a LIVE survival playtest on ANY platform (esp. the Apple TV) via a CF Worker relay the game POLLS — spawn any enemy/boss, build custom enemies (body × movement × weapon × stat sliders × element), edit live player stats, isolate (pause spawns + clear arena), survive (invuln + time-scale), and read back alive-counts/fps/hp. Spec docs/superpowers/specs/2026-06-25-remote-playtest-console-design.md, plan docs/superpowers/plans/2026-06-25-remote-playtest-console.md. 6 TDD’d tasks, each reviewed; determinism baseline 1432233777/2300319179 byte-identical (everything defaults OFF). Fully verified end-to-end on the Apple TV @ BUILD 58 (pause-menu arm + full panel + spawn/builder/player-editor all confirmed live).

  • Transport = telemetry.gd run backwards. tvOS/web can’t accept an inbound socket but CAN poll. net/control_client.gd (ControlClient extends Node, render-side, OFF until armed) GETs /poll?code=&after= draining commands → main.apply_dev_command(cmd), and POSTs /status + /caps. Armed by F6 (desktop) or the start-menu Remote Control card (ui/start_menu.gd remote_requested signal → control_client.enable(self)); a 6-char pairing code shows on the HUD and scopes commands to that instance. Dev builds (BuildConfig.dev_tools(), live platforms only) arm themselves at boot with a stable per-install code (see the Live console bullet below); store, TestFlight and web builds still need an explicit arm.
  • In-run PAUSE menu (ui/pause_menu.gd, BUILD 58) — the real arm + code-readout path on a TV. Before this, the game had ZERO pause/back-to-menu binding (only F2/F4/F6 keyboard + JOY_BUTTON_A to confirm in menus) — a controller’s Menu button did nothing because nothing listened. Now main._input opens a centered pause overlay on JOY_BUTTON_START/JOY_BUTTON_BACK or KEY_ESCAPE; _paused_for_menu gates _physics_process; the overlay shows the pairing code LARGE + centered and offers Arm Remote / Resume / Back to Start Menu (_return_to_menu reuses the _new_run child-sweep, sets sim=null, re-shows the menu). Dash=RB / decoy=LB, so Start/Back are free. TV-overscan lesson: the original top-right green REMOTE ▸ code HUD label was CLIPPED by the TV bezel — never put critical 10-foot UI at a screen edge; the centered pause menu is the readable home for the code.
  • Dev R2 = voice feedback (2026-10-06/07). In Dev builds (BuildConfig.dev_tools(), never web or store), a press edge on the controller’s right trigger (JOY_AXIS_TRIGGER_RIGHT, FeedbackTrigger on main, polled at the top of every _physics_process so it works with no run) opens FeedbackPanel in voice mode from any screen (start menu, live run, pause menu + sub-panels, level-up, death/results, shop, Bay, codex, dimension choice, dialogue, warp; 2026-10-07): kind OTHER, text box focused so the system keyboard comes up for dictation. It is main’s own overlay (main.feedback_overlay, layer 150), not the pause menu’s panel: while it is up a live run is frozen (_physics_process freeze condition and the _process timers), and InputFreeze switches off _input/unhandled/shortcut processing on every other node under main so nothing underneath reacts (the overlay’s TextEdit still gets its GUI keys); closing restores exactly those flags and the previous focus owner. R2 is ignored while any feedback panel is open; with Diagnostics off it does nothing (a live run mirrors the touch button: pause list on the Diagnostics toggle). The touch button / pause-menu “Send feedback” path is unchanged and never carries the voice marker. The note goes through the unchanged GameplayTelemetry.send_feedback with context.voice = true (lands in feedback.context_json; no new route or column). iOS dictates from the on-screen keyboard mic. tvOS needs the godot-tvos fork’s UIAlertController keyboard (fork commit 4001180, advertised as OS.has_feature("tvos_text_input"); an older template hides the text box and keeps quick notes): press R2, then dictate with the Siri Remote mic and pick Done. R1 (warp) / L1 (decoy) are unchanged. Overlay visibility (2026-10-07): the voice panel shows one accent hint line under its sub-label, FeedbackPanel.hint_text(): “B to close” while the pad is live, “Finish with Done on the keyboard” while the tvOS keyboard alert is up (that is exactly when _pad_blocked_by_keyboard swallows B); the touch / pause-menu panel has no hint. main also pushes feedback_open / feedback_close telemetry events (see Telemetry v2 below) so an accidental R2 open that froze the run is countable, not a silent “dead controller”.
  • Relay worker control/ (bullet-heaven-control.chris-allen-06f.workers.dev, CF Worker + D1 9c509410-…, separate from the telemetry worker). Panel served with Cache-Control: no-store so a stale cached copy can’t mask an update. GET / serves the panel fail-closed behind CONTROL_KEY (in ~/.secrets as BULLET_HEAVEN_CONTROL_KEY); POST /cmd enqueues, GET /poll drains-by-cursor+prunes, POST/GET /status + /caps. All four game-facing routes now also require CONTROL_KEY (fixed 2026-07-06 via the security-scan skill trial — they previously trusted only the ~31-bit pairing code with no rate limiting; control_client.gd embeds the key since this tool is dev/playtest-only, never in the shipping build). All POSTs guard malformed/missing bodies → 400 (never 500). queue.js is pure + node-tested (cd control && node --test). Deploy: cd control && CLOUDFLARE_API_TOKEN="$CF_LUMARA_DEPLOY_TOKEN" CLOUDFLARE_ACCOUNT_ID="$CF_ACCOUNT_ID" npx wrangler deploy. The panel page (src/panel.html.js) is caps-driven — every dropdown/slider from GET /caps, so it can’t drift from the game’s enums; server data rendered via textContent only (stored-XSS guard). Where a new dev-only store goes: the TELEMETRY worker’s POST ingest routes are open (no write auth, only STATS_KEY for viewing), and the panel CSP is connect-src 'self', so any dev-writable data the console must show belongs in control/ behind CONTROL_KEY (2026-10-11, learning-autopilot planning).
  • /sim dev seams (plain data, default OFF → baseline byte-identical): Sim.dev_suppress_spawns (early-return in _spawn_enemies), PlayerState.dev_invuln (makes is_invulnerable() true → covers EVERY damage site, the one guard), Sim.dev_clear_enemies() (drains enemies+enemy_proj, clears bombs/boss_missiles/boss_rockets/zones/webs/funzones/powerups), Sim.dev_spawn_custom(spec,pos), Sim.dev_set_player_stat(field,value) (ALLOW-LISTED to DEV_PLAYER_FIELDS — no arbitrary set()), Sim.dev_damage_mult (2026-07-10, default 1.0 — multiplies every hit at the single elemental_system.damage_enemy choke point), Sim.dev_force_grant_weapon(pilot,wid) (2026-07-10 — like UpgradeSystem.grant_weapon but bypasses max_weapon_slots, idempotent), and Sim.dev_level_weapon(pilot,wid,n) (up-only dev investment; applies the weapon’s normal power mod, or first listed mod, without touching RNG). The determinism test never arms any of these.
  • Custom enemies = decoupling body+weapon from type_id. EnemyPool.TYPE_CUSTOM=22 + new lockstep columns shape_id/attack_id (resized in _init, default -1 in add, swapped in remove_at). ArchetypeRenderer.shape_for(type_id, override) uses shape_id for custom; firing routes via Sim.ATTACK_* ids — bolt/fan/bomb in a self-contained _update_custom_attacks pass (deliberately NOT sharing helpers with _update_ranged to avoid churning the hot deterministic tick), beam/shards by broadening the lancer/orbiter guards (tid==TYPE_CUSTOM and attack_id==…). Determinism-safe: TYPE_CUSTOM never spawns in the baseline.
  • main.apply_dev_command dispatches spawn_preset/spawn_boss/spawn_custom (placement via render-side randf, NEVER sim.rng), clear/pause_spawns/invuln/timescale (render-side: _ts_accum-driven tick loop in _physics_process, default 1.0 = one tick/frame), player_stat, and (2026-07-10) damage_mult/add_gold/force_levelup/grant_weapon/remove_weapon, plus level_weapon (Sim.dev_level_weapon: up-only dev investment using one normal weapon mod per step, P1 only; it does not grant crystal rungs or trigger the live survival evolution path). build_caps()/build_status() publish the manifest + readout (the latter null-guards sim); caps weapons lists every auto weapon (Sim.dev_weapon_ids()) with its level-step kind, status weapons carries P1’s per-weapon name/active/effective level/mechanical level/crystal rungs/evolved state, and the panel’s Weapons section renders Grant/Remove/+1/+3/+5 rows from them (control/test/panel.test.mjs is included in scripts/gate.sh).
  • Parity pass (2026-10-01): every in-game Debug Settings row is now also a console command, and the panel is tabbed. New dispatch arms add_crystals (Sim.dev_add_crystals(element, n) — grants into the shared wallet and re-runs _eval_thresholds immediately, so crossing a WeaponThresholds rung applies that weapon’s real mod/evolution exactly as a picked crystal card would; the way back is remove_crystals/clear_crystals, which roll unmet rungs back on every pilot via _roll_back_unmet_rungs; dev_reset_upgrades empties the wallet and runs the same rollback for pilots other than the rebuilt P1), dimension (validated against DebugSettingsPanel.dimension_names(), then the same _apply_area_change a portal uses), graphics/background/tilt_mode (routed through DebugSettingsPanel.console_graphics/console_background/console_tilt so the in-game rows and the console share ONE tracked state and the same one-way quality_manager.auto = false flip), audio_bus, touch_flag and dev_flag (both validated against their roster first — the static setters fall through to their LAST field for an unknown name, so an unchecked typo would silently write the wrong flag). The toggle rosters (GRAPHICS_TOGGLES/TOUCH_TOGGLES/DEV_FLAG_TOGGLES/AUDIO_BUSES) are consts on DebugSettingsPanel that both front ends build from, and build_caps() publishes them verbatim alongside crystal_elements/backgrounds/tilt_modes/dimensions/pulse_looks/damage_mults plus the panel’s own SPAWN_ENEMY_NAMES/SPAWN_BOSS_NAMES (the console’s preset grid was a stale hand-copy of 16 of the 26 spawnable names). build_status() gained crystals (wallet + next rung on an owned weapon), gold/level/area, and flags (bus:/touch:/dev:/gfx: prefixed live toggle state + pulse_look) so the panel’s checkboxes show what the game is actually doing; a key the game doesn’t report yet leaves its checkbox alone rather than claiming “off”. The panel itself is now one tab per job (Run / Spawn / Player / Weapons / Crystals / Visuals / Settings / Readout) with a persistent live strip (fps, run time, hp, level, gold, area, enemy count), and it holds off applying incoming flag state for ~1.8s after a write so a checkbox doesn’t visibly snap back during the ~1Hz status round-trip.
  • Rule: every Debug Settings row is also on the Remote Playtest Console — enforced by tests/test_debug_console_parity.gd. It opens the real panel on a live run, walks every Button it built, and fails on a row with no console descriptor (ui/debug_settings_panel.gd), a kind control/src/panel.html.js never sends or apply_dev_command doesn’t handle (it now returns bool), an option missing from its build_caps() roster, or a console command that doesn’t change the state the row reads. New row = descriptor + caps roster + dispatch arm (+ console page if it isn’t caps-driven, which needs a control Worker deploy). Only Close is allow-listed panel-only.
  • The touch toggles roster’s haptics row is labelled “Haptics” (2026-10-05) and drives ALL haptics (HapticsOutput.enabled, persisted as meta.haptics by main); its console key stays touch:haptics.
  • Candidate looks (2026-10-01): rival weapon looks are switchable live, per weapon. The roster is DATA: DevFlags.looks(wid) (for each wid in DevFlags.CANDIDATE_WIDS: pulse, beam, railgun) is the off slot "v1" then every slug in fx/weapons/candidates/<wid>/_labels.json in letter order, read with FileAccess (works in an exported PCK; never DirAccess), so archiving a new model’s take is a folder + one labels entry, no code edit. The candidates folder is a permanent archive, never deleted (fx/weapons/candidates/README.md; integrity pinned by tests/test_candidate_archive.gd). The off slot is the weapon’s built-in look and is LABELLED v1 (legacy) or current once a look is promoted into FxManager.V2_LOOKS (DevFlags.off_label); DevFlags.candidate_look (persisted as candidate_look in user://dev_flags.json; the old pulse_look key is still read and written) holds the choice, whitelist-validated and forced off in non-dev builds. FxManager.set_candidate_look(wid, slug) injects the look through the runtime look map ("bolt", "beam" and "railgun_lance" events route by ev["wid"]; enemy beams carry no wid and stay legacy) and, when the folder also has a mark.gd (Railgun), installs it as mark_layer (hiding the built-in mark, if one is promoted); "v1" restores both. main._new_run re-applies every saved choice after fx_layer is rebuilt. Blind weapons (DevFlags.BLIND_LOOKS: beam, railgun) are labelled v1|current/A-D everywhere a person looks — the Debug Settings “Beam Look”/“Railgun Look” rows, the console’s look selectors, build_caps().weapon_looks, build_status().flags["look:<wid>"] — in their board’s letter order (candidates/<wid>/_labels.json, pinned by tests/test_candidate_look_switch.gd). The weapon_look command takes {wid, look: <label>} (an older console’s slug field is still accepted) and main resolves it with DevFlags.look_slug. tools/weapon_vfx/ingame.gd CANDIDATE=<slug> goes through the same set_candidate_look, names its output by the board letter, and waits for a FRESH spawn of an event look (beam/railgun looks live 0.17-0.5 s, so “already live” could photograph a faded lance).
  • Console v2 (2026-10-01): No XP, Reset everything, crystal rollback, neon mission-control panel. Sim.dev_no_xp gates Sim._bank_xp (kills + gems award nothing; console Level up/Grant/Crystals still work), also a Debug Settings row. reset_all = the reset_upgrades body (main._dev_reset_build)
    • dev_clear_enemies + dev_clear_gems in one command. remove_crystals/clear_crystals really undo rungs: every external weapon mutation appends to PilotArsenal.mod_log (src card/dev/rung:/evolve), and Sim._roll_back_unmet_rungs erases unmet thresholds_done keys then PilotArsenal.rebuild_weapons the weapon from _construct (the ONE wid→member match) replaying the log minus those rungs – the weapon’s live state (cooldowns, shots in flight, orbit phase, turrets, zones) is lost by design. No render/ui node caches a weapon object, so no rebuild event exists. New caps: element_colors, crystal_rungs (with effect labels), descs (DebugSettingsPanel.*_DESCS), dev_commands (feature-detect list; the panel marks absent ones data-stale). Status adds no_xp, xp, xp_to_next. Panel source is now string modules control/src/panel/{styles,markup,client,tooltips}.js composed by panelHtml() (no backtick/${/backslash inside – panel-modules.test.mjs); every data-tip / tip(el,'id') must have a TOOLTIPS entry and vice versa (panel-tooltips.test.mjs). CSP now allows Google Fonts (fonts.googleapis.com styles, fonts.gstatic.com fonts). Real-browser check: the stub-server recipe in plan docs/superpowers/plans/2026-10-01-remote-console-v2.md T10. Touch tooltips are a 400 ms long-press, and a long-press must never also press the control: once the tip shows, touchend (non-passive) calls preventDefault so no click follows, and mouse hover is ignored while a finger is down (Chrome fires a boundary mouseover mid-touch that used to cancel the hold). Before this, long-pressing Reset everything to read “Cannot be undone” sent reset_all (caught in the T10 emulated-touch check; pinned in panel-tooltips.test.mjs).
  • Looks card = instant buttons; What’s new tab (2026-10-02). Every Looks row (Background, Ship tilt, each caps.weapon_looks weapon, Web look) is a wrapping button group (segRow in client.js): a tap sends background/tilt_mode/weapon_look/web_look at once and lights that button; /status.flags keeps the lit one on what the game draws (bg + tilt come from DebugSettingsPanel.live_background/ live_tilt, read off the renderers, so an untouched background reports the run’s own random pick; look:<wid>, legacy pulse_look). The What’s new tab renders control/src/whats_new.js (embedded in the page as JSON): player-visible / testable changes per BUILD, newest first, each item with a where (console control or in-game place to test it), marked “in this build” / “not in this build yet” against the connected game’s caps.build. Whoever merges a player-visible change to main adds an item to the unbuilt entry (build: null, date: null, at the top; create it if absent, at most one). scripts/bump-build.sh stamps that line with N and today’s date when it runs on main or release/* (release.sh commits it with the bump); on any other branch it stays null, so a throwaway combined-test branch never claims main’s items. BUILD numbers are burned on parallel branches, so the per-card verdict is worded “probably” and the tab’s hint says a test-branch build can carry more or less than the list says. A dev install from main (install-dev.sh) also stamps it and prints a NOTE: commit control/src/whats_new.js in the same bump commit as sim/constants.gd. Shape pinned by control/test/whats-new.test.mjs, the stamping by scripts/test/bump-build-whats-new.test.mjs. The panel itself deploys with the Worker, not the game: a changelog edit needs a Worker deploy to show up.
  • Live console (2026-10-04): Dev builds are always connected, and balance numbers change mid-run.
    • Auto-arm + stable code. main._ready arms ControlClient when ControlClient.should_auto_arm(BuildConfig.dev_tools(), EventQueue.live_platform()); the code is generated once per install and kept in a small user:// store (on tvOS the purgeable cache, so a cache purge issues a new code). F6, the start-menu card and the pause menu’s Arm Remote still work and show the same code. The in-run REMOTE ▸ HUD label is hidden while the client is only auto-armed and appears after a hand arm. build_status() carries device (label, model, platform, build, mode or menu, run_time) and config (below).
    • D1 write budget. Polls are read-only. An unwatched device posts /status every 15 s (5,760 writes/day, the idle ceiling) and polls every 5 s; a watched one posts status at 1 Hz and polls every 1 s. “Watched” = the panel’s heartbeat: POST /watch {codes, key} every 10 s upserts a watch row with a 25 s TTL (8,640 writes/day per watched code). Only the one device selected in the header is ever watched (a broadcast does not watch every device), and the heartbeat stops while the tab is hidden (visibilitychange restarts it at once), so a forgotten background tab cannot hold a device at 1 Hz. /poll answers with X-Control-Watched: 1|0 and X-Control-Cursor (the code’s highest queued seq) on every poll (an old game ignores them, so /poll stays backward-compatible); boot=1 on the first poll returns only that cursor so a fresh launch never replays commands queued for the previous one, and a server cursor BELOW the client’s (a stable code suspended past the 24 h prune, whose queue restarted at 1) makes the client drop its cursor to 0 and drain the new queue. A missing watch table (worker deployed before the migration) reads as unwatched instead of failing /poll. Every /caps POST prunes status/caps/orphaned-command rows older than 24 h and expired watch rows.
    • GET /sessions?key= lists every code with a status row fresher than 45 s, echoing only scalar device fields (sessionFromRow). The panel header shows them as buttons (refreshed every 5 s); a click fills the code and connects. Manual code entry still works.
    • set_tunables command {kind, tunables?, bible?, reset?}: RemoteConfig.set_live validates each key (Tunables.check / BibleOverlay.check_one; null removes one key, reset:true drops the layer) into an app-session live layer (live_tunables/live_bible, never written to disk), and main._apply_live_config(RemoteConfig.apply_live(), source) applies it to the running game: tunables via Tunables.apply of the effective patch; bible edits rebuild the ContentDB and call Sim.dev_swap_content(content, wids) (re-reads enemy type tables, rebuilds edited weapons from their defs replaying PilotArsenal.mod_log, so levels and evolutions stay but cooldowns and deployed state reset); render caches re-read via LiveScope hooks (Nova evolve, nebula, arena background rebuild with its seed, area tint, enemy-projectile size). It works from the menu too (applied at the next run). The layer is re-applied at every run start until a reset. What is not live is data, net/live_scope.gd (LiveScope.TUNABLES/BIBLE): spawn-baked values (elite/wyrm multipliers, SpawnDirector curves, enemy hp/armor/contact/speed/biomass…) reach new spawns only; hazard generation waits for the next area; decoy life / drone HP for the next deploy; mod magnitudes for the next pick; meta-upgrade stats and QualityManager.WARMUP for the next run. status.config.pending lists the edited keys that are not live yet, problems what was refused. tests/test_live_scope.gd scans the source so a tunable read in one-shot code (spawn/setup/gen/deploy/evolve/_init…) cannot pass as live unclassified.
    • Panel Tunables tab (control/src/panel/live.js): rows generated from caps tunables (every TunablesRegistry entry: key, type, default, min/max or null, scope) and bible_fields (every overlayable bible field), filter + “edited only”, a scope chip per row, an edit sends set_tunables on change, a per-row × clears that key, Reset to published sends reset:true. The target select sends to this device or to all live devices (one /cmd per code). Current values come from status.config (live, else published, else default). All server strings go through textContent. control/test/panel-live.test.mjs.
    • Tunables tab groups + tooltips (panel-only, works with any build that publishes caps). Rows sit in collapsible sections (Enemy scaling, Spawning & waves, Enemy stats, Elites, Warden boss, one per weapon, Drones, …) with match counts, Open all / Close all, and a search over label, key and description. Hovering a value’s name shows a plain-English description plus default (with unit), range, when it lands and the raw key. The text is keyed by tunable key in the GENERATED control/src/tunable_docs.js, built by node tools/tunable_docs/generate.mjs from tools/tunable_docs/docs.json (curated label/desc/unit per key, src = taken from the source comment or written from the code that reads the value; per-key group override), the registry and the latest remote-config/manifest/build-N.json (bible keys + weapon/enemy display names). Groups come from GROUP_RULES in the generator; a key that matches no rule is an error. Adding a tunable: node tools/tunable_docs/generate.mjs --stubs prints a docs.json stub seeded from its comments; rewrite it as one or two plain lines (no dates, names or history), add it to docs.json and regenerate. control/test/tunable-docs.test.mjs fails on any undocumented registry key or manifest bible field, a key with no group, or a stale generated file. A key the docs do not know (a newer build) shows under Other with its raw key.
    • Dev-only readouts. A run that was changed mid-run shows + live edits in the pause menu’s patch block and its config_id carries +live; Dev builds toast each edited key top-right, Console: Sim.DASH_COOLDOWN = 0.6 (ui/patch_toast.gd; see the change-note bullet below). Store, TestFlight and web builds show neither (only the notice publish time is on every build). See docs/architecture/remote-config.md § Live dev path for the /config refetch, hash/telemetry and replay side.
    • On-device change note (2026-10-10). Commands from the console arrive through main.apply_console_command (the control client’s entry point), which runs apply_dev_command and, when it returned true on a Dev build, stacks a note on the patch toast naming the setting and its new value: Console: HDR output (A/B) ON, Console: Background = Galaxy, Console: Sim.DASH_COOLDOWN = 0.6. The text is the pure ui/console_change_note.gd (ConsoleChangeNote.notes_for(cmd, dev, main._console_ctx)), using the Debug Settings row label from the shared rosters, else the raw id. Toasts: graphics, touch_flag, dev_flag, audio_bus, background, tilt_mode, web/nebula/galaxy/weapon_look, dimension, qm_auto, timescale, damage_mult, invuln, pause_spawns, no_xp, player_stat, funzo_treatment, set_tunables (one line per key, 4 max then +N more values; reset says live edits cleared). A tunable line that does not land now says when: (next run) from the menu, for a sim key in LAN co-op or during a replay run (RemoteConfig.live_deferred()), (new spawns)/(next area)/… for a spawn-baked key. main._dev_set_tunables sets _console_ctx["deferred"] on the very branches that defer (and from LiveScope.pending on the live path), and _console_ctx["accepted"] to the keys RemoteConfig.set_live really holds, so a refused key or bible field says nothing and the note cannot guess. A removal (null) says KEY edit removed (it falls back to the published patch or the default). HDR output ON adds (no HDR renderer) or (screen not HDR) from HdrOutput.status. player_stat only for Sim.DEV_PLAYER_FIELDS, funzo_treatment only for a known id (by name). (From the menu the run’s toast is gone, so nothing shows there today.) Silent on purpose: one-shot actions whose result is already on screen (clear, spawn_, gold, crystals, weapon grant/remove/level, force_levelup, reset_), haptic_play, perf_scenario, lan_coop/det_probe (own toasts), funzo_hp_frac, and any id main would reject. Debug Settings rows call apply_dev_command directly, so a press on the device shows nothing. A burst stacks as lines (PatchToast.add_note; the same setting replaces its own line, so it always shows the current state), at most PatchToast.MAX_NOTES, fewer when the screen is short: on a touchscreen the stack stops above the aim stick and WARP/DRONE fan (iPhone has room for about three one-line notes). Hold time is the local tunable PatchToast.HOLD_S (Tunables tab, Dev tools).
  • Local DEV POWERS panel (2026-07-10) — ui/debug_settings_panel.gd, no browser/pairing needed. Toby: fast local playtest iteration was the whole point (“I wouldn’t have to just play the game for ages to test things”) — arming the Remote Console still needs a separate browser tab + CF relay round-trip per command, too slow for rapid local bisecting. This is a THIRD front end for the exact same apply_dev_command dispatcher the Remote Console already drives (no logic duplicated) — reachable straight from the in-run pause menu (Debug Settings, dev-build-only via BuildConfig.dev_tools(), same gate the whole panel already sits behind). New dev_command_requested(cmd) signal, wired once in main._ready alongside the pre-existing dimension_change_requested. Rows: Invincibility/Pause Spawns (toggles), Damage Mult/Time Scale (cycle ×1/×2/×5/×10/…), Add Gold (+1000/press), Force Level Up (+1/press — opens the REAL level-up panel so a dev can pick any of the 3 rolled cards, rather than trying to hardcode “apply upgrade X” buttons for every possible mod), Grant Weapon/Spawn Enemy/Spawn Boss (cycle + press, same “acts on the shown option, then advances” idiom as the pre-existing _dimension_row), Clear Enemies. open_panel(qm, sim) now takes sim too (was qm-only) so toggle rows can read live state the same way graphics rows read QualityManager. Sim._ENEMY_NAME_TO_TID (spawn_preset’s name→type lookup) gained 8 entries (centurion/wyrm/beacon/glass_wisp/halo_chain/leech/coil/fleet_kite) that existed on EnemyPool/in bible.json but were never reachable via a story/dev spawn before (story mode never authored an encounter using them).
  • Known limit: _dev_spawn_boss("warden") draws one sim.rng value (main._dev_spawn_boss → BossWarden.spawn(sim), which has no position param and picks its own spawn point from sim.rng) — perturbs the run’s spawn stream; acceptable (using the console already abandons reproducibility), documented in code.
  • Panel-reveal gotcha (shipped, fixed live): the panel’s tab panes (.pane, and before the 2026-10-01 tab rework the #spawn/#builder/#player/#survivability sections) get display:none from a STYLESHEET rule, so revealing one with el.style.display='' is a NO-OP (it only clears an inline style → falls back to the rule). Must set 'block'. It shipped because the build check only asserts the panel HTML string contains the sections — it never RUNS the panel. Lesson: verify any generated web panel in a real browser (or headless), not just a string/node smoke.
  • tvOS deploy is DONE (BUILD 58). When the tvOS repo was behind on MULTIPLE features (this console + the parallel Element Crystals work), I rsynced the WHOLE gameplay surface (ai audio content data fonts fx input meta net render sim tests ui + main.gd), not just the changed files — guarantees the tvOS build mirrors main. NEVER copy project.godot/icon.png/export_presets.cfg/.godot//builds/ (platform-only). main now also carries the Element Crystals ruleset (BUILD 57, the other agent), so BUILD 58 ships both.
  • Deferred polish (.superpowers/sdd/remote-playtest-console-progress.md): custom attacks use dt not edt; TYPE_CUSTOM has no distinct base colour (NEUTRAL fallback); a couple of weak tests to harden; the web demo R2 redeploy (the console is opt-in, never polls unless armed, so the public demo is unaffected).
  • Perf scenario (2026-10-01) — on-device render A/B, Perf tab. {kind:"perf_scenario", preset, arm, segments, seg_s, warmup_s, targets, webs, zones} creates render/perf_scenario.gd (dev builds only, survival only): reset_upgrades + clear + invuln + paused spawns, a canned preset granted through the real crystal thresholds (PerfScenario.PRESETS), then ABBA segments timed in sim seconds, the B side hiding one layer at frame_pre_draw (PerfRig.hide_arm: nova_shockwave, zones, webs, any v2 look). Segments are rejected and re-run (sim < 90% of wall time, a panel froze main, the arm’s look never live), giving up at 2× segments. Each segment → one perf_seg event (generic events table, JSON data; query json_extract(data,'$.scenario_id')); status().perf_scenario drives the panel table. Holds/hides are shared with tools/perf_probe via render/perf_rig.gd, so Mac and device measure the same scene. frame_ms = main-thread frame delta (≈ GPU time on a GPU-bound device); process_ms = TIME_PROCESS; render_cpu_ms from Telemetry.render_times (root viewport only, so not directly comparable with samples.render_cpu_ms, which also counts UPDATE_ALWAYS SubViewports), averaged over measured frames only (render_cpu_frames; null when none was measured, never 0.0). The scenario forces root measurement on at begin and hands it back to Telemetry._sync_measuring() at teardown. Arm webs/zones with a 0 count is refused at begin (that layer never exists under paused spawns). Main forwards render_events to it because main drains sim.render_fx_events before children run.
  • Colour profile + tonemapper + freeze scene (HDR/SDR colour systems step 1). {kind:"color_profile", profile:"auto"|"sdr"|"hdr"}, {kind:"tonemap_sdr"|"tonemap_hdr", mode:"linear"|"reinhard"|"filmic"|"aces"|"agx"}: session state in HdrLook (render-only, need no run, Debug Settings parity rows “Colour Profile” / “SDR Tonemap” / “HDR Tonemap”; caps color_profiles / tonemap_modes; flags look:profile|tonemap_sdr|tonemap_hdr; all three in control/src/guard.js SAFE_KINDS, look-only). {kind:"freeze_scene", on} (dev builds, guarded, console-only like perf_scenario, not LAN-safe): render/freeze_scene.gd gates on main._mode == "survival" (sim.ruleset is always SURVIVAL), builds the batch1_evolved + Warden + webs/zones/targets scene via PerfRig.DevHold (shared with PerfScenario) plus its own held ring of real enemy types (FreezeScene.SWARM, step 3; PerfRig untouched), runs 3 s of sim time, then main._sim_freeze_reason() returns "freeze_scene" until on:false; it reports through main._frozen_reason().
  • P3 gamut swatch (HDR P3 plan Task 3, device proof). {kind:"gamut_swatch", on, p3} (Dev builds only via main._gamut_swatch_command(cmd, dev), guarded, no Debug Settings row). ON needs a run: on the menu (sim == null) the StartMenu backdrop would hide it, so it is refused with the note “Gamut swatch not shown (start a run)”. ON adds render/gamut_swatch.gd as a direct child of main (the tonemapped world canvas): right column the P3 red/green primaries as extended linear sRGB, left column the same colours max(0)-clamped (what sRGB output does), so sRGB output shows identical pairs and P3 output a saturation gap only. p3 writes Viewport.output_gamut 1/0 through main.gamut_output (render/gamut_output.gd, the one door to the engine patch; tests swap in a fake). Whenever the swatch leaves the tree (OFF, _sweep_run_children on a new run or the menu) its tree_exiting runs _gamut_swatch_release, which restores the gamut the first ON found, so P3 never leaks past the check. Caps: dev_commands lists gamut_swatch, p3_output = engine has the patch (the console card says so). Status: gamut_swatch: {on, p3, engine_p3, output_gamut}. Change note: “Console: Gamut swatch ON, P3 output” (+ “ (no P3 engine)“).
  • Personas tab (2026-10-11) — learning autopilot archive. Reads GET /personas straight from the control Worker (control/src/personas.js), not from a device, so it works before pairing: with a console key and no paired device the tab bar shows Personas only (renderTabs(['personas'])); loadCaps restores the full bar. 4×3 grid (role × skill): each cell shows its elite (name, mean survival, samples) or, with no elite, its best candidate from the response’s cell_candidates (n/min_samples, clickable and pinnable); the finder (device kind) is in the detail box only. Detail = genome, lineage, Pin / Pinned state. Promotions chart (step line per cell), latest-runs feed (job, target → scored cell, survival, gold, finder, build, patch id), status line (epoch, cells held, pin + Clear pin), New epoch. Pin, Clear pin and New epoch are two-click confirms: the first click arms (label “Confirm: …”, 4 s window), a second click within 400 ms of arming is ignored (a double-click cannot fire), the button is disabled while its request runs. Re-polls every 15 s while the tab and browser tab are visible. min_samples comes from the response, never hard-coded. Learning on/off is the Settings-tab dev flag learning_autopilot, not this tab. Until migration 0003 is applied remotely and the Worker deployed, the tab reads “Archive read failed: HTTP 404”. Tests: control/test/panel-personas.test.mjs (drives the panel against the real Worker on the D1 stand-in). Architecture: docs/architecture/learning-autopilot.md.
  • Ratings prompt proof (2026-10-14) — {kind:"review_prompt_test"}, Dev builds only, menu or run. main._review_prompt_test_command calls ReviewPrompt.native_request() (the DcStoreReview singleton) directly, bypassing the store-channel / iOS-macOS / once-per-build gates, and never writes MetaState (the version’s real ask stays unspent). A Dev-signed build always shows Apple’s “Enjoying Dark Cosmos?” sheet, so this proves the native path on iPhone/iPad. Its own toast: Review request: made ... or NOT made (no DcStoreReview native side, or no active window scene) (a false is the dead-feature signal). No Debug Settings row (one-shot proof, like gamut_swatch); caps dev_commands lists it; console Dev card button “Request review”. Not a SAFE kind: the MCP guard refuses it while a human is playing (the sheet covers the game). Detail: docs/architecture/meta-and-story.md § Ratings prompt.
  • Launch cover preview (2026-10-16) — {kind:"boot_splash", secs}, Dev builds only, menu or run. main._boot_splash_command puts a fresh BootSplash (ui/boot_splash.gd, the launch cover: the engine boot splash picture redrawn at the same fit plus the cyan loading shimmer) up for secs (default 4, clamped 0.05-30), then releases it into its normal hold + fade. The real cover is gone before a console can connect, so this is how the BootSplash.* tunables (Visuals & FX, all local + live) are tuned by eye. Toast “Launch cover preview: N s”; caps dev_commands lists it. Not a SAFE kind: it covers the screen.
  • Per-tick determinism trace (issue #71) — scripts/det-trace.sh <good-rev> [ticks] [seed] [survival] [pilots]. When the pinned determinism tests fail, run this instead of revert-and-recheck: it traces the scripted scenario (per-tick hashes of pilots / enemies / enemy timers / projectiles / enemy projectiles / gems / drones / the four rng streams / clock, tools/determinism_trace/trace_core.gd) on a clean worktree at <good-rev> and on this tree, and prints the FIRST diverging tick and group (exit 1), then writes reports/det-trace-{good,this}-<tick>.txt full-value dumps to diff. Dev only; a different group tells you where to look (rng = a draw moved, enemies = spawn/move, pilots = input/damage). Caveat: a draw moved inside a spawn burst can surface first as enemies with rng unchanged (the shifted stream consumed the same number of draws that tick; reproduced at tick 122 with the 1617-draw opening burst), while an isolated draw (tick 181) flags rng at 182. Read an enemies-only first divergence as “possibly a moved rng draw” and compare the detail dumps. A legit re-pin still goes through bh-determinism-repin.
  • Determinism probe (2026-10-07, dev-infra audit Tier 0) — {kind:"det_probe"}, start menu only. net/determinism_probe.gd runs NetCoopRun.build_sim(1234, 2 pilots) for 3,600 ticks with scripted inputs, auto-picked level-ups and dev_invuln, recording Sim.net_checksum() every 600; main._det_probe_tick steps it 60 ticks/frame on shipped default tunables + the bundled bible (Tunables.snapshot/reset, then Tunables.restore), refuses during a run and aborts (without restoring) if a run starts underneath it. The result lands in status().det_probe (console card), a toast, and a det_probe event (generic events table; json_extract(data,'$.digest')). Only compare digests from the SAME BUILD — any gameplay change moves them; install one build on every device first. Checkpoints are not pinned (that would need a re-pin per gameplay change); the GUT test proves self-consistency and chunking independence only.

Agents drive the console through the local stdio MCP dark-cosmos-console (tools/console_mcp/, registered in the repo-root .mcp.json), never raw curl. Tool list, config and tests: tools/console_mcp/README.md. Spec: docs/superpowers/specs/2026-10-08-console-mcp-design.md.

  • Tools. sessions (live devices + human_playing), status (trimmed; full:true for the raw blob), send_command, telemetry_query (one read-only SELECT/WITH … SELECT against production telemetry D1, max 200 rows), ab_test.
  • The human-is-playing guard. The game reports status.human = {input_age_ms, autopilot}: input_age_ms is the time since the last real human input, stamped from the root window’s window_input signal so an event a menu consumes still counts; -1 means none since boot. A command kind outside SAFE_KINDS is refused with HTTP 409 {error:"guarded", reason} while input_age_ms ≤ 20000 (human_playing, window cut from 120 s by Chris 2026-10-11), or when no status fresher than 30 s proves otherwise (status_unknown, so fail closed). The source of truth for the safe list and the windows is control/src/guard.js, imported by both the Worker’s /cmd and the MCP, so they cannot disagree. The panel bypasses it with source:'panel' (a human is at the console); the MCP never sends that.
  • override etiquette. override: true (strict boolean) skips the guard. Use it only when Chris has explicitly asked for that command on a device he is playing, with his words in reason. Never override past a refusal on your own judgement: report the refusal.
  • Reading ab_test. It runs ≥3 ABBA rounds of one graphics toggle (3 s settle per side, 1 Hz fps samples, /watch every 20 s), reading the toggle’s LIVE default first, and in finally restores the toggle and then qm.auto (a console graphics press turns the adaptive ladder off), verified against a status row newer than every row read before the restore. It refuses, or aborts mid-test, while a human plays or status goes stale. conclusive is true only when the side means differ by more than sd_a + sd_b: conclusive:false means noise, not “no effect”, so rerun with a longer seconds_per_side. scene_drift means the scene changed size under the test, so the delta may be the scene, not the toggle. insufficient_samples means the device posted too few fresh status rows. Check restored is all true after every run.
  • Status fields agents read. human and qm = {auto, tier} in both menu and run status; perf = {draws, proc_ms, funzones, webs} in a run; flags["gfx:<effect>"] for every graphics toggle from the first status, reporting the live default before any console press. {kind:"qm_auto", on} turns the adaptive quality ladder back on.
  • Deploy order: game build → Dev install → Worker deploy. A Worker deployed before devices report human refuses every guarded command as status_unknown, and a panel tab opened before the deploy must be reloaded (it lacks source:'panel').

Swap a candidate WAV into a running Dev build mid-run and hear it through the real audio chain, then revert. Front ends: the Sound Map’s Send to device button (per candidate row, targeting the row’s variant slot: <key>, <key>_N, or a per-boss spawn_<boss> / enrage_<boss> slot) with Revert <key> / Revert all and a device picker (tools/sound_map/server.py /api/audition, /api/audition/revert, /api/devices, shelling out to tools/console_mcp/audition.mjs; start the server after source ~/.secrets), and the console MCP tools audition_sound {code, key, file, play?, wait?} / audition_revert {code, key?} (tools/console_mcp/lib/audition.mjs, shared by both). The Sound Map always sends with --wait and its alert reports the device’s verdict (on / failed: … / not reported), never a bare “Sent”.

  • Transport. The WAV goes up first: POST /sfx?key= on the control Worker (control/src/sfx.js, raw bytes, ≤ 1 MB, RIFF/WAVE shape check) stores KV aud-<sha256> with a 24 h TTL (no cleanup job); a re-send of a blob already there skips the write (KV writes are the scarce quota), and a KV failure answers JSON 503 with the reason. The SFX binding is the telemetry Worker’s existing CONFIG namespace: aud- never overlaps the published sfx-<sha> sound-pack blobs or build- patches, and an audition can never put a TTL on a published blob. The client checks the file with wavProblem (same rules as RuntimeWav) before uploading and refuses a Worker answer whose sha/bytes differ. Only then is the small command queued: {kind:"sfx_audition", key, sha256, bytes, play?}.
  • Device. main.apply_dev_command routes sfx_audition / sfx_audition_clear {key?} (no key = all) to main.sfx_audition (net/sfx_audition.gd) before the sim guard, so it works on the menu, mid-run and in LAN co-op; Dev builds only (BuildConfig.dev_tools(); store builds return false and show no note). It downloads <ControlClient.ENDPOINT>/sfx/<sha>.wav?key= (the URL is never taken from the command), one at a time, verifies with RemoteSounds.verify (byte count, SHA-256, RuntimeWav.check: the same code as remote sound packs) and installs it with AudioManager.set_audition(key, stream), then previews it once (AudioManager.preview: the stream as sent on a priority pool voice, never the looped copy) unless play:false. Verified blobs stay cached in memory (12) so a re-send of the same take installs instantly. A revert sent before the download lands wins, and a revert + re-send of the same take while it downloads rides the in-flight job (installs and plays once). A revert or re-send stops any pool voice still sounding the old take. status().sfx_audition = {key: {sha (12 hex), state: loading | on | failed: <why> | refused: <why>}} (a refused command, e.g. a variant slot this build lacks, is recorded for a sound-key shaped key so a --wait sender sees the reason); on-device notes Console: Audition <key> loading / on / failed (...)/ reverted.
  • Audition layer. AudioManager._audition is checked before the remote-pack _overrides in _stream_for and _slot_or_fallback, is set and cleared mid-run, and is never touched by the run-start set_overrides (so a pack and an audition never clobber each other; a revert falls back to the pack, else bundled). It works in both banks (a candidate under test is not part of the old/new A/B). An auditioned base key replaces its whole round-robin family; an auditioned <key>_N only that variant (bank 2). Looping keys (orbit_ambience) loop on a per-key copy. Session-only: an app restart drops every audition.
  • Guard. Both kinds are in control/src/guard.js SAFE_KINDS (audio-only, no gameplay change), so they pass while a human is playing: that is the point.
  • Deploy. The control Worker needs a deploy after merge (new route + SFX KV binding). The device side needs a Dev install. Unverified on device (as of 2026-10-14): the download and runtime AudioStreamWAV playback have run headless on macOS 4.7 only; RuntimeWav fills the stream itself (no load_from_buffer), so the tvOS 4.8 fork needs no new API, but it has not been heard there.
  • Console boss spawns now play their cue (fixed with this work): main._dev_spawn_boss hands the director’s between-tick fx events to audio.consume at once and appends them to sim.render_fx_events; before, the next tick’s fx_events.clear() dropped them (and the first Warden opens the codex, which freezes the sim, so waiting for the next tick was not an option).

Console autopilot toggle (Dev, 2026-10-14)

Section titled “Console autopilot toggle (Dev, 2026-10-14)”

Hand a live run to the autopilot mid-run and take it back, e.g. to listen to an SFX audition hands-free on the Apple TV (Chris). Panel: the Run tab’s Autopilot tile (tile-autopilot, a RUN_TOGGLES entry, lit from run status.autopilot, greyed out via caps.dev_commands on a game that predates the kind). MCP / agents: send_command {kind:"autopilot", on:true|false}.

  • Device. main.apply_dev_command routes autopilot to main._autopilot_command(cmd, dev) before the sim guard; Dev builds only (store builds return false, no note). ON sets _auto through _set_auto and flies the run’s own attract AI (auto_player, built by _new_run for every run: a SkilledAutoPlayer, or the persona on a learning run). A level-up or Dimension panel already open when ON lands stays the human’s pick (the sim is frozen under it, so no take-over could stop an AI pick): the note reads Console: Autopilot ON after your pick, and the AI picks from the next panel. OFF hands the ship back. A no-op (already in that state) changes nothing.
  • Hand-over semantics unchanged: any real human input (main.human_takes_over: stick, aim, warp, drone tap) still takes the ship straight back, so a console ON does NOT survive the next stray press (a drifting stick past the input dead-zone included). Press it again to hand back.
  • Refused (a known kind; the on-device note says Console: Autopilot not changed (<why>)) on the menu and death screen (start a run), in LAN co-op, in local co-op (not in local co-op: with two pilots each ship reads its own pad, so the AI would fly nobody), during a replay playback or capture, and in story + tutorial (survival runs only: _new_run keeps those human-flown on purpose, lesson gates and dialogue wait for a human). Lift the story refusal in main._autopilot_refusal if ever needed.
  • Telemetry marking. Any change it makes sets main._console_autopilot_run (per run), which adds console_autopilot: true to run_end, the run body’s repro block and runs.timeline_json (the Worker drops the unknown run field and stores the timeline verbatim, no migration). tools/economy_sim/human_targets.py drops runs carrying it (CONSOLE_AUTOPILOT_MARK), because such a run is mixed and would otherwise count as a pure human run. Once the console has changed a run, main._reports_auto() is false for the rest of it: the event envelope’s auto (which the Worker keeps as runs.is_auto), the run body’s is_auto and the autopilot identity (auto_profile etc.) all report a human run, so is_auto = 1 + profile still means “the AI flew it from tick 0; seed + profile replay it” and the Worker’s AI-vs-human summaries never count a mixed run as AI.
  • Learning runs. ON never starts a persona job (jobs are picked only at run start), so a taken-over learning run handed back to the AI never posts; OFF on a learning run still flying cancels its job, like a human take-over. A learning run that dies on the autopilot still arms the attract chain, as before (the chain only ever follows a learning run, so a human-started run never chains).
  • Replays (DevFlags.record_replay): recording continues (the ticks hold the inputs actually used, so playback stays exact) and ReplayRecorder.autopilot_events = [{tick, on}] notes every hand-over (main._set_auto: console ON/OFF, the human take-over after it, P2 join, and a recording that starts on the autopilot as {tick: 0, on: true}). Empty = a human flew every tick. Informational only: playback (ReplayLoader) ignores it and does not need it (the ticks are the inputs actually used). No tool reads it yet: any human-vs-bot replay comparison (the learning-autopilot gap plan) must cut the on segments out, or skip a replay whose list is non-empty.
  • Guard. autopilot is NOT in control/src/guard.js SAFE_KINDS: Chris’s own panel press bypasses the guard (source:'panel'), so his hands-free use while holding the controller is unaffected, but an agent’s ON while he plays would hand his run to the AI and mark it, so the MCP is refused for human_playing like any run-touching kind. When Chris asks an agent for it, send override:true with his words in reason.
  • Deploy. The panel tile needs a control Worker deploy after merge; the kind needs a Dev install (older builds grey the tile out). No Debug Settings row (the parity rule is Debug Settings → console, not the reverse); no What’s new entry (not player-visible).

Story Board — dialogue/pacing review tool (tools/story_board/, 2026-07-23)

Section titled “Story Board — dialogue/pacing review tool (tools/story_board/, 2026-07-23)”

Lets Chris review the Tutorial’s dialogue beats (screenshot + text + timing) side-by-side and pin comments back to Claude in one batch, instead of narrating feedback live over a playthrough. Design: docs/superpowers/specs/2026-07-23-story-board-tool-design.md.

  • 6-stage pipeline: (1) record a real Tutorial run to a replay fixture — turn on Debug Settings → “Dev: record replays” (dev build / editor, next launch; windowed only), play it, it saves under user://replays/; (2) capture re-ticks that replay through the real game in a windowed Metal SubViewport (tools/story_board/capture.gd/capture.tscn), screenshotting the actual scene at each beat’s trigger tick; (3) it writes out/storyboard.json — one beat card per say event (tools/story_board/beat_log.gd’s BeatLog.build(), pure/unit-tested: id, line count, gap to next beat, nearest trigger, and refs into the code/data that control it) plus the matching out/beat-*.png screenshots; (4) the web tool (this section) renders a timeline strip + one card per beat and lets Chris type a comment + pick a tag per beat; (5) Save POSTs the non-empty pins to tools/story_board/annotations.json; (6) Claude reads that file, actions the pins as one batch (rewording lines, adding a pause, re-triggering a beat, etc.), and Chris re-captures on the same replay to confirm the fix before the next round.
  • Run it:
    • Capture (WINDOWED — MultiMesh colours are unreadable headless, same constraint as the UI-sweep harness): godot --path . res://tools/story_board/capture.tscn --rendering-method forward_plus (replay path via env STORY_REPLAY, default user://replays/tutorial.json). ⚠️ Any windowed godot --path . invocation can silently corrupt project.godot (see docs/architecture/godot-gotchas.md) — git diff project.godot right after and git checkout -- project.godot if it shows changed, before it lands in a commit.
    • Serve + review: python3 tools/story_board/server.py, then open http://localhost:8081/tools/story_board/index.html (stdlib-only server, same convention as tools/sound_map/server.py — serves the repo statically from REPO_ROOT and handles POST /story_board/save).
  • Testable core: renderBeatCard(beat) and collectPins(state) in src/app.js are pure functions, unit-tested via Node’s built-in node:test (node --test tools/story_board/tests/app.test.js) — the same guarded module.exports/typeof window bootstrap pattern as tools/sound_map, so the one file is both Node-testable and browser-runnable with no build step.
  • Scope: Tutorial (Ember Reach) only for now, not the full Story campaign — see NEXT_TASKS.md.

Live site — ~/Claude/bullet-heaven-site/ (separate repo)

Section titled “Live site — ~/Claude/bullet-heaven-site/ (separate repo)”

Public game site — canonical https://darkcosmos.lumara.digital (renamed from the hyphenated dark-cosmos.lumara.digital 2026-07-14, which now 301-redirects via a zone-level dynamic redirect rule, path-preserving; https://bullet-heaven.lumara.digital also still serves the same site — all three are custom domains on the one CF Pages project bullet-heaven) — neon landing + the Balance Bible at /bible/ + a playable Godot web demo embedded from R2 (the demo iframe still points at bullet-heaven-play.lumara.digital — not user-visible, rename deferred). ⚠️ scripts/deploy-site.sh stages a clean-dir ALLOWLIST PUBLIC=(index.html 404.html favicon.svg bible privacy) — a NEW public page/dir must be added to PUBLIC or it silently won’t deploy (adding /privacy/ needed this). It also verifies /privacy/ is 200 now. Hosting is split: landing+bible on CF Pages, the Godot web build on CF R2 at bullet-heaven-play.lumara.digital (the 36MB wasm exceeds CF Pages’ 25MB/file limit). The web build is exported from THIS repo (export_presets.cfg, Web preset, no-threads variant → builds/web/) and uploaded to R2. Full deploy + re-export instructions live in ~/Claude/bullet-heaven-site/CLAUDE.md. The /bible/ on the site is a COPY of tools/design-bible/ (this repo is the source of truth).

  • One-command demo deploy: scripts/deploy-demo.sh re-exports the Web build and uploads it to the R2 bucket, then verifies the live Content-Length matches (fail-loud). Run after game changes. It does NOT touch the landing/Bible (a separate CF Pages deploy in the site repo). The web demo is CURRENT at BUILD 258 as a DEV build (--dev, 2026-10-04: channel dev, @dev patches, console auto-arm, Debug Settings; testers only, Chris’s call), linked from the site’s /play/ page. --dev embeds the console’s CONTROL_KEY in the public pck: redeploy without --dev and rotate the key before the demo goes properly public. The root export_presets.cfg is gitignored/local-only, so the script itself asserts the Web preset excludes audio/music/ audio/candidates/ tools/ tests/ telemetry/ addons/gut/ marketing/ concept-art/ docs-site/ docs/ perf-data/ and fails if index.pck ≥ 250 MiB (tools/vfx_library’s baked captures once pushed it to 494 MiB, past wrangler’s 300 MiB R2 cap). Pages-deploy gotcha (bit us 2026-06-23): wrangler pages deploy does NOT honor .assetsignore and walks the dir OFF DISK, so it will upload internal files (CLAUDE.md, .claude/) and make them public — deploy the site from a CLEAN dir (only index.html/404.html/favicon.svg/bible/), or physically move internal files out first. A file removed from a later deploy keeps serving a stale 200 from CF Pages’ own asset cache (7-day s-maxage, immune to zone purge) — the instant fix is a CF Dynamic Redirect for that path (one already guards /CLAUDE.md → landing in the lumara.digital dynamic-redirect ruleset).

The full project documentation is live at https://dark-cosmos-docs.lumara.digital in Astro Starlight. Content is wired from this repository, with directory-level symlinks for architecture/, reference/, cloud-briefs/, research/, ideas/, and specs-and-plans/; roadmap/ and releases/ use one manual link per document. New files in directory-linked source folders appear automatically. The site deploys from GitHub through CF Pages project dark-cosmos-docs; changes to docs-site/*, docs/*, DESIGN.md, PRODUCT.md, or NEXT_TASKS.md trigger a build. The design spec is docs/superpowers/specs/2026-07-04-docs-site-design.md.

Every document needs fenced frontmatter containing a title: line above its existing H1. scripts/test/docsite-frontmatter.test.mjs enforces this and also enforces wiring completeness for every docs/**/*.md. Do not symlink images from ignored marketing/raw/ or concept-art/gemini/ into docs-site/public/; copy them, resizing large generated images to WebP first (for example, magick in.png -resize 1200x out.webp). After pushing documentation, verify the specific page in the sitemap, not only that a CF build is Active.

Spec: docs/superpowers/specs/2026-09-28-build-channels-pipeline-design.md.

  • Dev app: bash scripts/dev/install-dev.sh <atv|iphone|ipad> installs Dark Cosmos Dev (digital.lumara.bulletheaven.dev, own saves, DEV-badged icon on iOS) next to the store app. --no-bake (with or without --no-bump, any order) is a DIAGNOSTIC install with the shader baker off for tvOS-Dev/iOS-Dev (preset restored by trap, check_baked skipped; first-use stalls expected, never for release), for confirming the A17 iPhone baker draw bug (godot#124088).
  • One build number: scripts/bump-build.sh is the only writer; store build == Sim_Const.BUILD; tests/test_release_presets.gd enforces it. On main/release/* it also stamps the Remote Console’s What’s new unbuilt entry (control/src/whats_new.js), which belongs in the same bump commit. release.sh tags every upload store-N. Merge release/* back to main before main’s next release, or both will try the same BUILD (release.sh’s store-N pre-check fails fast).
  • Channel: BuildChannel.current() (dev/web/testflight/appstore, failure → appstore) rides on every telemetry/feedback row (channel, channel_probe columns) and GET /config. Filter real players with WHERE channel='appstore'; rows before BUILD 207 are NULL.
  • Release pre-flight (2026-10-05, after tvOS 259 failed twice post-build): release.sh (iOS + tvOS) and build-macos.sh (macOS) run asc.py check-version <short_version> --platform … before the bump/export: it fails if Apple already APPROVED that version or a higher one on a target platform (the train is closed: 90062/90186) or the platform has no App Store record. Raise application/short_version in all seven Apple presets together (apple_short_version refuses a mismatch). seed_build_dirs (env.sh) copies the gitignored platform/tvos/build/ + build-ios/ (ExportOptions.plist, the dummy.mm save bridge) from the main checkout when a worktree lacks them, so release/<N> worktrees release cleanly. altool_run keeps the full altool log beside the ipa/pkg and prints every error on failure.
  • Dev modes: DevFlags (Debug Settings → “Dev: …”, dev builds only, next launch).
  • Android (2026-10-01, sideload/itch first, Play later): presets Android / Android-Dev live in platform/tvos/export_presets.cfg (that project’s .mobile="mobile" gives Android the Mobile/Vulkan renderer; the root project would give it gl_compatibility). Plain APK export, no Gradle build yet (Play needs an AAB = Gradle). version/code == BUILD via bump-build.sh. Toolchain: JDK 17 (brew openjdk@17, set as Godot’s java_sdk_path), SDK at ~/Library/Android/sdk (platform 36, build-tools 36.1.0, NDK 29). Release key: ~/.android-keys/dark-cosmos-release.keystore, alias darkcosmos, password DARK_COSMOS_ANDROID_KEYSTORE_PASSWORD, SHA-256 25:FB:04:04:…:A2:17; backed up (file + password) in 1Password achris. It’s OUR key so itch and Play builds share one signature: when Play is set up, upload it to Play App Signing (“use my own key”), never let Google generate one, or itch↔Play users must uninstall (and lose saves) to switch. Release export: GODOT_ANDROID_KEYSTORE_RELEASE_{PATH,USER,PASSWORD} env + godot --headless --path platform/tvos --export-release Android builds/android/DarkCosmos.apk. Sentry is excluded from both presets. Tester APKs ship via skill bh-android-tester-build (scripts/release/android-tester-build.sh). Open: BuildChannel labels any non-dev Android build appstore.
  • macOS / Mac App Store (2026-10-04): preset macOS in platform/tvos/export_presets.cfg (universal arm64+x86_64, same bundle id as iOS/tvOS = Universal Purchase). That project’s rendering_method.macos="mobile" gives the Mac Forward Mobile on Metal + HDR glow; the root project’s local macOS preset is gl_compatibility (no glow) and is for quick dev runs only. scripts/release/build-macos.sh exports windowed (shader baker), adds the Game Mode/controller plist keys, embeds the MAC_APP_STORE profile (~/.appstoreconnect/profiles/DarkCosmos_MacAppStore.provisionprofile), signs with Apple Distribution + platform/tvos/patches/macos.entitlements (App Sandbox + network client), productbuilds a pkg signed by 3rd Party Mac Developer Installer, validates and uploads via altool (NO_UPLOAD=1 stops after validate). Sandboxed saves live in ~/Library/Containers/digital.lumara.bulletheaven/Data/Library/Application Support/Godot/app_userdata/Dark Cosmos/. Not yet in release.sh. Open: BuildChannel labels Mac TestFlight builds appstore (the Mac receipt is Contents/_MASReceipt/receipt for both).
  • App Clip channel (2026-10-15, plan docs/superpowers/plans/2026-10-15-app-clip-warden.md; NOT in the store app until Chris approves, target 1.1 with a drop rule). Ids: store parent digital.lumara.bulletheaven + clip digital.lumara.bulletheaven.Clip; Dev parent digital.lumara.bulletheaven.dev + clip digital.lumara.bulletheaven.dev.Clip (“Dark Cosmos Dev”). One App Group per channel, group.$(BH_APP_ID): group.digital.lumara.bulletheaven (store) and group.digital.lumara.bulletheaven.dev (Dev); parent and clip both carry it (the clip writes clip_rewards.json there, the full app reads it for the Beta-skin grant). Presets iOS-Clip / iOS-Clip-Dev. Prerequisite (developer portal, by hand: Xcode with the ASC API key cannot add it, “Authentication failed”): register group.digital.lumara.bulletheaven and group.digital.lumara.bulletheaven.dev; enable App Groups on digital.lumara.bulletheaven, .Clip, .dev and .dev.Clip, and tick the matching group on each. Without it the clip pack exports (36 MB, 61 baked canvas versions on 2026-10-15) but signing fails. Dev install: scripts/dev/install-clip-dev.sh <iphone|ipad> (clip only) or --with-parent (also the parent Dev app from the same shell, the only Dev parent with the App Group, needed for the clip → full app handoff test; replaces the installed Dev app). It never bumps BUILD and works on its own platform/tvos/build-ios-clip/ shell, so install-dev.sh’s shell never gets the clip target. Local Experience testing: iPhone Settings → Developer → Local Experiences → register a URL (e.g. https://darkcosmos.lumara.digital/clip) for bundle digital.lumara.bulletheaven.dev.Clip, then open it from Safari / Messages and check the clip card launches the clip. Release (opt-in): BH_WITH_CLIP=1 scripts/release/build-ios.sh exports the iOS-Clip pack, checks the bake and DcAppClip, adds the DarkCosmosClip target (scripts/release/add-app-clip-target.sh) and then, after the normal export, clip_size_gate (env.sh) re-exports the SAME archive thinned (release-testing, all variants) and runs scripts/release/check-clip-size.mjs --report on “App Thinning Size Report.txt”: it fails the release above 95 MB uncompressed for any clip variant (Apple’s iOS 17+ cap is 100 MB). The Dev script runs the weaker --app pre-check on the built bundle. Without BH_WITH_CLIP the release is unchanged. The export method name and report filename are [Likely], to be confirmed on the first real archive.
  • devicectl device ids ROTATE (OS updates / re-pairing): install-dev.sh hardcodes the three ids, and a rotated id fails with a silent “A connection to this device could not be established” at xcodebuild/devicectl, not at the script’s start. Refresh them from xcrun devicectl list devices (rotated + fixed 2026-09-30 in 1203123e; the old July ATV id had gone dead).

relay/ = bullet-heaven-relay (CF Worker + one RelayRoom Durable Object per room code; stores nothing). Architecture: docs/architecture/coop-and-pilots.md § Internet co-op (relay, Phase 4); endpoints, close codes, limits and the full deploy sequence: relay/README.md.

  • Deploy only via bash relay/deploy.sh (--check-only / --dry-run / real). It refuses unless the relay node tests pass (incl. the stores-nothing and compatibility-date checks) and relay/ is clean, then checks /health. The first deploy of this public endpoint needs Chris’s OK.
  • Worker before client: no client build whose RelayTransport.DEFAULT_URL points at the relay may be installed before that Worker is live.
  • Pre-deploy / pre-merge check: scripts/relay-local-test.sh (two headless Godot clients through wrangler dev --local; not in scripts/gate.sh). Post-deploy, the same script with RELAY_URL=wss://bullet-heaven-relay.chris-allen-06f.workers.dev … 3600 200 proves a lone host survives 3+ min idle on the real edge.
  • The player gate is a remote-config features key, TestFlight-only until 1.2: docs/architecture/remote-config.md § Features.

Spec: docs/superpowers/specs/2026-09-29-telemetry-v2-design.md. Plan (with the deviations from the spec, the as-built notes and the Chris-gated steps G1-G13): docs/superpowers/plans/2026-09-29-telemetry-v2.md. Research that motivated it: docs/research/2026-09-28-telemetry-gaps-and-options.md.

One pipe. Every reporter pushes onto EventQueue (net/event_queue.gd, persistent node made in main._ready), never HTTP directly. EventQueueCore (pure) keeps pending events on disk (telemetry_queue.txt, user:// or the cache dir on tvOS; line = <id>\t<type>\t<json>), caps them at 1 MB (oldest dropped first), and POSTs up to 20 per request to POST /events. The first flush happens straight after boot; after that it flushes every 10 s or at 20 pending, immediately on background, run end and feedback, and backs off 10 s → 300 s on failure. A batch is dropped only on a 2xx whose ok+dup+bad equals what was sent, or on a 400/413. The Worker dedupes on the event id, so resends are safe.

Envelope (every event): id, t, ts, install, launch, session, build, channel, dev, auto, d. install = MetaState.install_id (random, rotated on re-enable); launch = per process (also rotated on re-enable); session = per run (shared by samples, hitches, run, run_end, encounters, feedback).

Types. sample, hitch, run, encounter, feedback also land in their old tables (plus install_id, launch_id, event_id); every type lands in events (for the legacy types data is NULL, since the row is in its own table). Lifecycle types: launch (carries channel_probe, first_launch, OS/model), prev_exit (how the LAST launch ended: crash, killed_in_background, web_closed, from the always-armed exit_marker.json, net/exit_marker.gd), menu, tutorial_step, shop_buy, run_start, run_end (reason: death, story_complete, tutorial_cap, quit_menu, dev_restart), lan_run (exactly one per networked session = per NetCoopRun, main._lan_report_session, guarded by _net_reported; reason = the run_end reason when the run ends first, session_end when the session ends under a live run (desync / host_left / timeout, pushed at once from _net_on_ended so an app kill after it loses nothing), closed when this device closes it mid-run (console leave / re-open), lobby when it ended before a run started (rejected / host_timeout), cli_quit for --mp-ticks; a lobby cancelled before it connected or failed sends nothing. Fields: role, pilot, ticks, end_reason, build, transport (NetTransport.kind(): enet / loopback / relay), link quality from NetSessionTelemetry (rtt_ms / loss_pct = last measured, rtt_mean, rtt_max, loss_max, link_samples: sampled once a second of wall time in _net_physics plus once at report; -1 when never measured; on the relay the RTT is end-to-end, PING/PONG peer to peer through the Durable Object, and loss is always -1 because it is TCP), relay_failure (relay sessions only, absent on LAN: RelayTransport.failure(), the wire-table reason the relay closed, e.g. host_left / connection_lost / kicked; "" = it never failed; the relay’s datacenter is deliberately not captured), on a desync desync_snapshot = Sim.snapshot_string() capped at 2 KB (sim state only; the Worker’s /events cap is 512 KB per batch of 20, events.data truncated at 480 000 chars), and stalls = client frames after its first tick that ran no tick while rows were due (network; pause-menu/READY-beat holds still count), pause_waits = frames waiting on a known level-up pause (not stalls), cushion_waits = stalls spent refilling the jitter buffer, jitter_rows = LockstepSession.JITTER_ROWS (client only), checksums, desync_tick; compare device runs’ stalls before/after a pacing change; relay sessions: SELECT json_extract(data,'$.role') role, json_extract(data,'$.ticks') ticks, json_extract(data,'$.rtt_mean') rtt_mean, json_extract(data,'$.rtt_max') rtt_max, json_extract(data,'$.stalls') stalls, json_extract(data,'$.cushion_waits') cushion, json_extract(data,'$.end_reason') end_reason, json_extract(data,'$.relay_failure') relay_failure, json_extract(data,'$.reason') reason FROM events WHERE type = 'lan_run' AND json_extract(data,'$.transport') = 'relay' ORDER BY rowid DESC LIMIT 20 via bh-telemetry / the console MCP telemetry_query; lan_run is a generic events row, so no Worker change or migration), background, foreground, metrickit (iOS), levelup_action (issue #77: one per level-up reroll or skip, main._on_levelup_reroll / _on_levelup_skip; payload from LevelUpTelemetry.action_payload: action reroll / skip, run_time, level, pilot, shown card ids, rerolls_left, xp_refund; a generic events row, so no Worker change or migration; run_end also gains rerolls_used / levelup_skips (summed over pilots) and rerolls_owned, and timeline_json keeps ONE row per level: a reroll annotates the open row (6th column = the later offers’ ids, RunTimeline.note_rerolled; RunTimeline.offers_of expands it for replay_compare), so picked is the level’s final action, which may be #skip; the replay header carries rerolls_owned, restored on playback), feedback_open / feedback_close (Dev R2 voice overlay only, so always dev builds: open carries state = main._telemetry_state() of the screen underneath, plus run_time and enemies only when a run exists; close carries reason = submit / cancel (B or Cancel) / freed (run sweep or return to menu with it up) and open_ms; pushed in main._open_voice_feedback after the Diagnostics gate and _end_voice_feedback after its idempotence guard, so one close per open). Query: SELECT json_extract(data,'$.reason'), COUNT(*), AVG(json_extract(data,'$.open_ms')) FROM events WHERE type='feedback_close' GROUP BY 1. runs stays “banked runs only”; a quit or a kill shows up as run_end / prev_exit, not a row. runs.timeline_json = level-ups [t, level, offered, picked, pilot] (net/run_timeline.gd), XP by enemy type (sim.xp_by_type), damage by weapon, boss log [type, spawn_t, kill_t, entity_id] (sim.boss_log; kill_t -1 = alive at run end, -2 = despawned). Difficulty (2026-10-15, migrations/2026-10-15-runs-difficulty.sql): runs.difficulty and the run_start / run_end events carry main._difficulty_tag(): normal / hard in Survival, '' in Story/Tutorial; the Worker stores NULL for anything else or an older client (pre-1.1 = Hard tuning). Run reproduction (2026-10-09, migrations/2026-10-09-run-repro.sql): runs.seed (main’s run_seed, the value Sim.new got), runs.ship / runs.drones (what MetaRunSetup.apply was given; '' = not applied, LAN co-op), plus auto_profile / auto_humanize / auto_version from GameplayTelemetry.autopilot_fields (NULL on a human run; the Worker also forces NULL when the envelope auto is 0). auto_profile = SkilledAutoPlayer.profile_id(): skilled-throttled for the attract demo, a SkillProfile name or custom for ProfiledAutoPlayer, basic for a plain AutoPlayer. auto_version = SkilledAutoPlayer.AUTOPILOT_VERSION: bump it on any pick/movement/aim logic change (2 = Rift Bolt aims at stacked enemies, 2026-10-12) and compare autopilot runs only within one version. run_start and run_end events carry seed + the three auto_* keys too. Together with build, config_id, mode, ruleset and meta_levels (end-of-run levels; run_start has the start-of-run ones) that is a run’s full input except human stick input and wall-clock-paced menu timing.

Controller liveness (2026-10-07, “the controller stops responding, Bluetooth?”): net/pad_liveness.gd, fed by main every in-run physics frame and by Input.joy_connection_changed. Render-side only; all three land in events.data. pad_conn {device, connected, pad_class (xbox/playstation/neutral/remote, never the raw name), pads_now, playable_now, pad_age_ms, state, run_time, enemies, last_frame_ms, max_frame_ms}, at most 20 per run, any screen. input_gap (one-shot, at most 5 per run) when a human-flown live fight (play/boss, nothing frozen, enemies up) has had no controller event for PadLiveness.GAP_S (tunable, 6 s) after the pad was active this run: {quiet_ms, hp_lost, axes [[dev, lx, ly, rx, ry]], latched, pads_now, state, run_time, enemies, last_frame_ms, max_frame_ms}. Not gated on HP or on a zero stick: latched (an axis held past 0.2 with no events) separates a stuck stick from a quiet one. input_resume {gap_ms, run_time} on the next event. Activity = InputRouter.last_pad_activity_ms (any button edge or a stick push past 0.2; the Siri Remote never counts). Reading them: a pad_conn disconnect near a player report = Bluetooth; a gap whose max_frame_ms matches it = an app stall, not the pad; a long gap + resume with small frame times = the pad went quiet (sleep / interference). Frame times here are physics-frame wall gaps measured by PadLiveness itself (no dependence on the per-run Telemetry node).

npx wrangler d1 execute bullet-heaven-telemetry --remote --command \
"SELECT ts, type, dev, build, data FROM events WHERE type IN ('pad_conn','input_gap','input_resume') ORDER BY ts DESC LIMIT 50"

Perf fields. samples.render_ms and hitches.proc_ms are Performance.TIME_PROCESS, the whole frame’s process time, NOT “our script” and NOT rendering (the old dashboard reading was wrong). Godot’s TIME_PROCESS / TIME_PHYSICS_PROCESS monitors are each the worst value of the last whole second (engine main.cpp resets them once a second), so a 5 s sample reads 1 of its 5 seconds and a hitch’s proc_ms/phys_ms is that second’s worst, not the stalled frame’s own. samples.sim_ms (legacy name; dashboard “phys step ms”) is TIME_PHYSICS_PROCESS: the worst whole physics step, i.e. every node’s _physics_process (input, the autopilot planner on auto=1, sim tick(s), audio consume(), haptics, the LAN pump) plus the physics servers. It is spiky and NOT the sim’s cost. The sim’s cost is samples.tick_ms / tick_max_ms / ticks (2026-10-07): main times every sim.tick (local loop around tick_single; LAN loop via LockstepSession.clock_us / on_tick_us, injected in _start_net_run) and calls Telemetry.note_sim_tick(usec); each sample sends that 5 s window’s mean, worst single tick and tick count (take_tick_window()). Catch-up and time-scaled steps each count; a frozen window (level-up, pause) sends ticks 0 and NULL ms. Compare builds on tick_ms, never by averaging it with sim_ms. Columns: telemetry/migrations/2026-10-07-sim-tick-telemetry.sql (apply before deploying the Worker). Real render cost: samples.render_cpu_ms = render CPU time of the root viewport plus every UPDATE_ALWAYS SubViewport, plus frame setup (Telemetry.render_times_from). It skips UPDATE_WHEN_VISIBLE SubViewports (threat radar, codex card) and reads one stale value per run; both are follow-ups in docs/RELEASE-1.0.1.md. Expect roughly 0.5-20 ms on device (check G10). samples.gpu_ms is NULL on Metal (tvOS/iOS: the godot-tvos fork has no timestamp queries) and WebGL; it only fills on desktop Vulkan/D3D12/GL. Samples still in the 5 s window are flushed on pause and on focus-out (iOS/tvOS), so a backgrounded session isn’t lost. Hitches: HitchSampler (net/hitch_sampler.gd) sends every first-use (new:) or pipeline-compile stall, otherwise the worst 2 per 30 s, at most 200 per run; a sampled hitch’s ts is back-dated to when it happened (up to 120 s earlier than it was sent).

Querying (from telemetry/, account id is not a secret):

export CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc
npx wrangler d1 execute bullet-heaven-telemetry --remote --command \
"SELECT type, COUNT(*) n FROM events WHERE ts > (strftime('%s','now')-86400)*1000 GROUP BY type"
# sessions that ended without a banked run, and why
npx wrangler d1 execute bullet-heaven-telemetry --remote --command \
"SELECT json_extract(data,'$.reason') reason, COUNT(*) FROM events WHERE type='run_end' GROUP BY 1"
# crashes / background kills reported on the next launch
npx wrangler d1 execute bullet-heaven-telemetry --remote --command \
"SELECT build, dev, json_extract(data,'$.reason'), json_extract(data,'$.state'), json_extract(data,'$.recent') FROM events WHERE type='prev_exit' ORDER BY ts DESC LIMIT 50"

Share diagnostics. Pause menu → “Diagnostics On/Off”, paired on one row with “Send feedback” (MetaState.share_diagnostics, default on, saved; a separate diag_off.json marker overrides the save, so an opt-out survives even when the save file can’t be read or written). Off: queue wiped, pushes become no-ops, install id cleared and dropped from GET /config, Sentry closed and its own ids wiped, MetricKit files deleted unsent, and “Send feedback” shows as a disabled “Feedback off” (feedback rides the same queue). On again: fresh install and launch ids, so the two periods can’t be joined. Privacy answers: marketing/appstore/compliance.md.

MetricKit (iOS only). platform/tvos/patches/ios/metrickit-sink.mm is APPENDED to the export’s dummy.cpp (renamed dummy.mm) by scripts/release/apply-ios-metrickit.sh (run by build-ios.sh and install-dev.sh), which also adds -weak_framework MetricKit to the shell’s OTHER_LDFLAGS (a .linker_option in the object file is ignored by ld). The sink writes <Caches>/mk-<kind>-<ms>-<rand>.json, and net/metrickit_pickup.gd sends each file as a metrickit event (d.raw = the JSON text, not re-parsed: Godot floats would corrupt 64-bit addresses) at boot, again 5 s and 30 s after boot (RESCAN_AFTER_BOOT_S: a crash report can land just after the boot scan, and a player who never foregrounds again would otherwise never send it), and on foreground. Not on tvOS (MetricKit doesn’t exist there). Symbolicating a report: match the binaryUUID against dwarfdump --uuid of a local build (the Dev .app + .dSYM under platform/tvos/build-ios/DerivedData-dev/, libdc_haptics under platform/tvos/addons/dc_haptics/out/) and run atos -o <binary> -arch arm64 -l 0x100000000 <0x100000000 + offset>; worked example in docs/research/2026-10-14-first-play-crashes.md.

Sentry (iOS only, dormant). sentry-godot 2.2.0 is fetched (sha256-pinned) into the gitignored platform/tvos/addons/sentry/ by scripts/release/fetch-sentry.sh. platform/tvos/addons/ is a real directory holding a gut symlink plus the fetched sentry/. [sentry] options/auto_init=false; net/sentry_boot.gd initialises only on iOS, with platform/tvos/sentry_dsn.json present (gitignored, Chris supplies) and diagnostics on, and sets the Sentry user to our install id. Sentry keeps its own ids (<user data>/sentry.dat, Cocoa’s <Caches>/INSTALLATION and <Caches>/io.sentry/); opt-out closes the SDK and deletes all three. Cocoa caches the id in memory, so after an opt-out Sentry does NOT re-init in the same launch; it resumes (with a fresh id) next launch. close flushes events that were already queued while the player was opted in. The tvOS presets exclude addons/sentry/* (no tvOS library upstream) and sentry_dsn.json.

  • platform/tvos/project.godot comments must use ;, not #: with #, Godot swallowed the [sentry] section that follows.
  • always_track_call_stacks.tvos=false always, and .ios=false while there is no DSN, so dormant Sentry costs no script time. When the DSN is added, delete the .ios override (G7); scripts/release/sentry-guard.sh dsn-vs-override (run by build-ios.sh) and tests/test_release_presets.gd both refuse a DSN with the override still present.
  • Dev iPhone installs need a full iOS export first. A --export-pack build only links libsentry if build-ios/ last came from a full export with the addon fetched; sentry-guard.sh ios-shell (run by install-dev.sh) stops with the exact commands (full export, then cp patches/ios-BulletHeaven-Info.plist build-ios/BulletHeaven/BulletHeaven-Info.plist, then re-run install-dev.sh).
  • Checking out a commit from before Task 9 (45dc6cc) makes git treat platform/tvos/addons as a symlink again: the ignored addons/sentry/ is deleted and sentry_dsn.json is no longer ignored. Re-run fetch-sentry.sh after coming back, and never commit the DSN.
  • Probe: tools/sentry_probe/probe.gd.

Audio fields (2026-09-29, audio perf audit §6). Every sample also carries aud_us (usec in AudioManager.consume() + helpers; part of the physics step sim_ms reads, not of tick_ms), aud_plays, aud_capped (dropped by the per-frame cap or a SFX_COOLDOWN_TICKS cooldown), aud_voices_max, aud_steals; a run’s first sample adds aud_mix_rate + aud_latency_ms. Columns: telemetry/migrations/2026-09-29-audio-telemetry.sql, applied AFTER the v2 one. Dev A/B: DevFlags.audio_sfx_off (Debug Settings, live) skips every SFX play.

Frozen-clock fields (2026-10-07). Every sample carries state (main _telemetry_state()) and every sample + hitch carries frozen + frozen_reason (main _frozen_reason(): feedback, gameover, levelup, dimension, victory, warp, pause, codex, lobby, dialogue; "" = the sim ticks). _sim_freeze_reason() is now the single list _physics_process freezes on, so the label cannot drift from the freeze again (272/273 sent 25 min of frozen 42 fps rows labelled play). state uses the same names for frozen screens; modal now means a content panel that does NOT stop the sim (codex used to read modal, now codex). Columns: telemetry/migrations/2026-10-07-frozen-state.sql, applied after the audio one. Older clients store NULL.

Deploy order is fixed. D1 migrations (telemetry/migrations/2026-09-29-telemetry-v2.sql, then 2026-09-29-audio-telemetry.sql, then 2026-10-07-frozen-state.sql, one statement at a time) → bash telemetry/deploy.sh --check-only (refuses while a column is missing) → Worker deploy via telemetry/deploy.sh → smoke test. The Worker’s old routes write the new columns, so deploying first breaks every insert. A v2 client that ships before the Worker deploy keeps its events queued (404 = retry), so nothing is lost, but keep builds away from testers until the smoke test passes.

Dev builds upload each archived input replay (replay-<unix>-<seed>.json, never replay-latest.json) to POST /replay on the telemetry Worker (spec docs/superpowers/specs/2026-10-14-replay-auto-upload-design.md).

  • Client net/replay_uploader.gd (ReplayUploader, persistent child of main): gate = Dev channel, not web, not muted, Share diagnostics on, live platform. The files on disk are the queue: one at a time, oldest first, gzip body via request_raw; deleted on 200 {"ok":true} or a 400/413, kept on network error/429/5xx with the event queue’s 10 s → 300 s backoff. A pass is kicked at boot and after every replay save, and never runs while a human run is in progress.
  • Worker: gzip ≤ 8 MB, decompressed ≤ 64 MB (413), shape seed int / fresh_save bool and seed == query seed (400), 200 objects per UTC day (429, KV CONFIG key replay_count:<YYYY-MM-DD>, 2-day TTL). The decompressed body is never materialized (a Worker has 128 MB): the gunzip is streamed, only the head before "ticks":[ is parsed (the recorder’s sorted-keys JSON always writes ticks last), and tick rows are counted as ],[ boundaries into customMetadata ticks. Caps raised 2026-10-22 from 2/16 MB: a 20-min analog run is 6.5 MB raw / 2.42 MB gzip and the client deletes on 413. Stores the original gzip at replays/<build>/<install>/<unix>-<seed>.json.gz in R2 bh-replays (binding REPLAYS), custom metadata build/install/unix/seed/ticks. An existing key answers 200 dup.
  • Retention: R2 lifecycle rule expire-365 deletes objects after 365 days (the privacy page’s 12 months), set once by hand: cd telemetry && npx wrangler r2 bucket lifecycle add bh-replays expire-365 --expire-days 365. R2 is not in RETENTION_TABLES (D1 only).
  • Mac: scripts/replays-pull.sh lists bh-replays (Cloudflare REST API; wrangler has no object list) and downloads every object not already in gitignored replays/ (same <build>/<install>/ layout, gunzipped), printing tick count and duration. Hand-pulled pre-upload copies live in replays/hand-pulled/. Compare against a bot run with tools/replay_compare (--replay=replays/...).

Crash symbolication (dSYMs) — checked 2026-09-29

Section titled “Crash symbolication (dSYMs) — checked 2026-09-29”

Question: when a tester crash shows up in Xcode Organizer, will the engine frames be readable? Checked on the last local archives with dwarfdump --uuid, nm and atos (commands: telemetry v2 plan Task 10, Step 1; run from platform/tvos/). No archive exists under ~/Library/Developer/Xcode/Archives/; release.sh archives into the build shells, so these are the only ones.

Archive Platform / build Engine UUIDs match Main::iteration in dSYM atos result
build-ios/BulletHeaven.xcarchive iphoneos, 37 (2026-09-28) stock 4.7.1 (no custom_build string) yes, D10596A7-BE85-35DA-A448-68D81AB80D3F 1 Main::iteration() (in BulletHeaven) + 0
build/BulletHeaven.xcarchive appletvos, 37 (2026-09-28) 4.7.dev.custom_build (fork) yes, 50BA1FF0-0B12-3F01-8E12-CEF3C9A058F9 1 Main::iteration() (in BulletHeaven) + 0
build/BulletHeaven-tvos.xcarchive appletvos, 11 (2026-06-30, stale) 4.7.dev.custom_build (fork) yes, F126564F-2135-3A88-B591-37900C28267F 1 Main::iteration() (in BulletHeaven) + 0

Supporting evidence (same runs):

  • The shipped binary is stripped (~1,080 / ~810 / ~800 symbols in nm of the app binary); the dSYM carries the full symbol table (~267k iOS, ~247k tvOS). So names come only from the dSYM.
  • An offset address resolves the same way: atos at Main::iteration + 0x40 gives Main::iteration() (in BulletHeaven) + 64; a mid-function address in the GDScript VM gives GDScriptFunction::call(GDScriptInstance*, Variant const**, int, Callable::CallError&, GDScriptFunction::CallState*) (in BulletHeaven) + 256. No file:line on any engine frame.
  • dwarfdump --debug-info on each dSYM lists exactly two compile units, both Xcode-shell code (dummy.cpp / dummy.mm and Xcode’s generated GeneratedAssetSymbols.swift). No engine source has DWARF, on either platform.
  • Upload path: both ExportOptions.plist files set uploadSymbols = true, and each exported ipa-out/BulletHeaven.ipa contains Symbols/<UUID>.symbols with the archive’s UUID (checked with unzip -l). altool --upload-app sends that IPA, so App Store Connect receives the symbols with the build.

What it means:

  • Full file:line: no, on both platforms. [Certain] from the dSYM contents above.
  • Function names: yes, on both platforms (iOS stock templates and the tvOS fork alike). [Certain] that the dSYM and the uploaded .symbols resolve names locally with atos. [Likely] that Organizer shows the same Function + offset frames, since it symbolicates from the uploaded symbols matched by UUID; not yet seen on a real Organizer crash report.
  • Nothing: does not apply. Every archive’s UUIDs match, so none was rebuilt after its dSYM.
  • A Godot crash inside script code still shows only VM frames (GDScriptFunction::call), never the .gd file or line. That gap is what Sentry (Task 9) is for, not something dSYMs can fix.
  • To get file:line, the export templates themselves must be built with debug info (Godot SCons debug_symbols=yes, then repackage the template zip: ios.zip under 4.7.1.stable, and the fork’s tvos.zip under 4.7.dev per bh-appstore-release §0.55). dsymutil can only copy DWARF that the linked static library already has. [Likely] that this alone is enough; the telemetry v2 spec left it out of scope. Expect a much larger template and dSYM, though the shipped binary stays stripped.

Keep uploading dSYMs with every archive (release.sh does, via the xcodebuild export with uploadSymbols = true). A local crash log can be symbolicated with the matching archive: atos -o <archive>/dSYMs/BulletHeaven.app.dSYM/Contents/Resources/DWARF/BulletHeaven -arch arm64 -l <load address> <frame address>.