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.mdon 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. SeeCLAUDE.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.gdremote_requestedsignal →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_Ato confirm in menus) — a controller’s Menu button did nothing because nothing listened. Nowmain._inputopens a centered pause overlay onJOY_BUTTON_START/JOY_BUTTON_BACKorKEY_ESCAPE;_paused_for_menugates_physics_process; the overlay shows the pairing code LARGE + centered and offers Arm Remote / Resume / Back to Start Menu (_return_to_menureuses the_new_runchild-sweep, setssim=null, re-shows the menu). Dash=RB / decoy=LB, so Start/Back are free. TV-overscan lesson: the original top-right greenREMOTE ▸ codeHUD 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,FeedbackTriggeron main, polled at the top of every_physics_processso it works with no run) opensFeedbackPanelin 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_processfreeze condition and the_processtimers), andInputFreezeswitches 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 unchangedGameplayTelemetry.send_feedbackwithcontext.voice = true(lands infeedback.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 commit4001180, advertised asOS.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_keyboardswallows B); the touch / pause-menu panel has no hint. main also pushesfeedback_open/feedback_closetelemetry 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 + D19c509410-…, separate from the telemetry worker). Panel served withCache-Control: no-storeso a stale cached copy can’t mask an update.GET /serves the panel fail-closed behindCONTROL_KEY(in~/.secretsasBULLET_HEAVEN_CONTROL_KEY);POST /cmdenqueues,GET /polldrains-by-cursor+prunes,POST/GET /status+/caps. All four game-facing routes now also requireCONTROL_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.gdembeds the key since this tool is dev/playtest-only, never in the shipping build). All POSTs guard malformed/missing bodies → 400 (never 500).queue.jsis 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 fromGET /caps, so it can’t drift from the game’s enums; server data rendered viatextContentonly (stored-XSS guard). Where a new dev-only store goes: the TELEMETRY worker’s POST ingest routes are open (no write auth, onlySTATS_KEYfor viewing), and the panel CSP isconnect-src 'self', so any dev-writable data the console must show belongs incontrol/behindCONTROL_KEY(2026-10-11, learning-autopilot planning). /simdev seams (plain data, default OFF → baseline byte-identical):Sim.dev_suppress_spawns(early-return in_spawn_enemies),PlayerState.dev_invuln(makesis_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 toDEV_PLAYER_FIELDS— no arbitraryset()),Sim.dev_damage_mult(2026-07-10, default 1.0 — multiplies every hit at the singleelemental_system.damage_enemychoke point),Sim.dev_force_grant_weapon(pilot,wid)(2026-07-10 — likeUpgradeSystem.grant_weaponbut bypassesmax_weapon_slots, idempotent), andSim.dev_level_weapon(pilot,wid,n)(up-only dev investment; applies the weapon’s normalpowermod, 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 columnsshape_id/attack_id(resized in_init, default -1 inadd, swapped inremove_at).ArchetypeRenderer.shape_for(type_id, override)usesshape_idfor custom; firing routes viaSim.ATTACK_*ids — bolt/fan/bomb in a self-contained_update_custom_attackspass (deliberately NOT sharing helpers with_update_rangedto 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_commanddispatchesspawn_preset/spawn_boss/spawn_custom(placement via render-siderandf, NEVERsim.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, pluslevel_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-guardssim); capsweaponslists every auto weapon (Sim.dev_weapon_ids()) with its level-step kind, statusweaponscarries 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.mjsis included inscripts/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_thresholdsimmediately, so crossing aWeaponThresholdsrung applies that weapon’s real mod/evolution exactly as a picked crystal card would; the way back isremove_crystals/clear_crystals, which roll unmet rungs back on every pilot via_roll_back_unmet_rungs;dev_reset_upgradesempties the wallet and runs the same rollback for pilots other than the rebuilt P1),dimension(validated againstDebugSettingsPanel.dimension_names(), then the same_apply_area_changea portal uses),graphics/background/tilt_mode(routed throughDebugSettingsPanel.console_graphics/console_background/console_tiltso the in-game rows and the console share ONE tracked state and the same one-wayquality_manager.auto = falseflip),audio_bus,touch_flaganddev_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 onDebugSettingsPanelthat both front ends build from, andbuild_caps()publishes them verbatim alongsidecrystal_elements/backgrounds/tilt_modes/dimensions/pulse_looks/damage_multsplus the panel’s ownSPAWN_ENEMY_NAMES/SPAWN_BOSS_NAMES(the console’s preset grid was a stale hand-copy of 16 of the 26 spawnable names).build_status()gainedcrystals(wallet + next rung on an owned weapon),gold/level/area, andflags(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 noconsoledescriptor (ui/debug_settings_panel.gd), a kindcontrol/src/panel.html.jsnever sends orapply_dev_commanddoesn’t handle (it now returns bool), an option missing from itsbuild_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). OnlyCloseis allow-listed panel-only. - The touch toggles roster’s
hapticsrow is labelled “Haptics” (2026-10-05) and drives ALL haptics (HapticsOutput.enabled, persisted asmeta.hapticsby main); its console key staystouch:haptics. - Candidate looks (2026-10-01): rival weapon looks are switchable live, per weapon. The roster is DATA:
DevFlags.looks(wid)(for each wid inDevFlags.CANDIDATE_WIDS:pulse,beam,railgun) is the off slot"v1"then every slug infx/weapons/candidates/<wid>/_labels.jsonin 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 bytests/test_candidate_archive.gd). The off slot is the weapon’s built-in look and is LABELLEDv1(legacy) orcurrentonce a look is promoted intoFxManager.V2_LOOKS(DevFlags.off_label);DevFlags.candidate_look(persisted ascandidate_lookinuser://dev_flags.json; the oldpulse_lookkey 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 byev["wid"]; enemy beams carry no wid and stay legacy) and, when the folder also has amark.gd(Railgun), installs it asmark_layer(hiding the built-in mark, if one is promoted);"v1"restores both.main._new_runre-applies every saved choice afterfx_layeris rebuilt. Blind weapons (DevFlags.BLIND_LOOKS: beam, railgun) are labelledv1|current/A-Deverywhere 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 bytests/test_candidate_look_switch.gd). Theweapon_lookcommand takes{wid, look: <label>}(an older console’sslugfield is still accepted) and main resolves it withDevFlags.look_slug.tools/weapon_vfx/ingame.gd CANDIDATE=<slug>goes through the sameset_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_xpgatesSim._bank_xp(kills + gems award nothing; console Level up/Grant/Crystals still work), also a Debug Settings row.reset_all= thereset_upgradesbody (main._dev_reset_build)dev_clear_enemies+dev_clear_gemsin one command.remove_crystals/clear_crystalsreally undo rungs: every external weapon mutation appends toPilotArsenal.mod_log(src card/dev/rung:/evolve), andSim._roll_back_unmet_rungserases unmetthresholds_donekeys thenPilotArsenal.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(witheffectlabels),descs(DebugSettingsPanel.*_DESCS),dev_commands(feature-detect list; the panel marks absent onesdata-stale). Status addsno_xp,xp,xp_to_next. Panel source is now string modulescontrol/src/panel/{styles,markup,client,tooltips}.jscomposed bypanelHtml()(no backtick/${/backslash inside –panel-modules.test.mjs); everydata-tip/tip(el,'id')must have aTOOLTIPSentry and vice versa (panel-tooltips.test.mjs). CSP now allows Google Fonts (fonts.googleapis.comstyles,fonts.gstatic.comfonts). Real-browser check: the stub-server recipe in plandocs/superpowers/plans/2026-10-01-remote-console-v2.mdT10. 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) callspreventDefaultso no click follows, and mouse hover is ignored while a finger is down (Chrome fires a boundarymouseovermid-touch that used to cancel the hold). Before this, long-pressing Reset everything to read “Cannot be undone” sentreset_all(caught in the T10 emulated-touch check; pinned inpanel-tooltips.test.mjs).
- Looks card = instant buttons; What’s new tab (2026-10-02). Every Looks row (Background, Ship tilt,
each
caps.weapon_looksweapon, Web look) is a wrapping button group (segRowinclient.js): a tap sendsbackground/tilt_mode/weapon_look/web_lookat once and lights that button;/status.flagskeeps the lit one on what the game draws (bg+tiltcome fromDebugSettingsPanel.live_background/ live_tilt, read off the renderers, so an untouched background reports the run’s own random pick;look:<wid>, legacypulse_look). The What’s new tab renderscontrol/src/whats_new.js(embedded in the page as JSON): player-visible / testable changes per BUILD, newest first, each item with awhere(console control or in-game place to test it), marked “in this build” / “not in this build yet” against the connected game’scaps.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.shstamps that line with N and today’s date when it runs onmainorrelease/*(release.shcommits 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 frommain(install-dev.sh) also stamps it and prints a NOTE: commitcontrol/src/whats_new.jsin the same bump commit assim/constants.gd. Shape pinned bycontrol/test/whats-new.test.mjs, the stamping byscripts/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._readyarmsControlClientwhenControlClient.should_auto_arm(BuildConfig.dev_tools(), EventQueue.live_platform()); the code is generated once per install and kept in a smalluser://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-runREMOTE ▸HUD label is hidden while the client is only auto-armed and appears after a hand arm.build_status()carriesdevice(label,model,platform,build,modeormenu,run_time) andconfig(below). - D1 write budget. Polls are read-only. An unwatched device posts
/statusevery 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 awatchrow 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 (visibilitychangerestarts it at once), so a forgotten background tab cannot hold a device at 1 Hz./pollanswers withX-Control-Watched: 1|0andX-Control-Cursor(the code’s highest queued seq) on every poll (an old game ignores them, so/pollstays backward-compatible);boot=1on 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 missingwatchtable (worker deployed before the migration) reads as unwatched instead of failing/poll. Every/capsPOST prunes status/caps/orphaned-command rows older than 24 h and expiredwatchrows. 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_tunablescommand{kind, tunables?, bible?, reset?}:RemoteConfig.set_livevalidates each key (Tunables.check/BibleOverlay.check_one;nullremoves one key,reset:truedrops the layer) into an app-session live layer (live_tunables/live_bible, never written to disk), andmain._apply_live_config(RemoteConfig.apply_live(), source)applies it to the running game: tunables viaTunables.applyof the effective patch; bible edits rebuild the ContentDB and callSim.dev_swap_content(content, wids)(re-reads enemy type tables, rebuilds edited weapons from their defs replayingPilotArsenal.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 andQualityManager.WARMUPfor the next run.status.config.pendinglists the edited keys that are not live yet,problemswhat was refused.tests/test_live_scope.gdscans 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 capstunables(everyTunablesRegistryentry: key, type, default, min/max or null, scope) andbible_fields(every overlayable bible field), filter + “edited only”, a scope chip per row, an edit sendsset_tunableson change, a per-row × clears that key, Reset to published sendsreset:true. The target select sends to this device or to all live devices (one/cmdper code). Current values come fromstatus.config(live, else published, else default). All server strings go throughtextContent.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 bynode tools/tunable_docs/generate.mjsfromtools/tunable_docs/docs.json(curatedlabel/desc/unitper key,src= taken from the source comment or written from the code that reads the value; per-keygroupoverride), the registry and the latestremote-config/manifest/build-N.json(bible keys + weapon/enemy display names). Groups come fromGROUP_RULESin the generator; a key that matches no rule is an error. Adding a tunable:node tools/tunable_docs/generate.mjs --stubsprints 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.mjsfails 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 editsin the pause menu’s patch block and itsconfig_idcarries+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). Seedocs/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 runsapply_dev_commandand, 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 pureui/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;resetsayslive 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_tunablessets_console_ctx["deferred"]on the very branches that defer (and fromLiveScope.pendingon the live path), and_console_ctx["accepted"]to the keysRemoteConfig.set_livereally holds, so a refused key or bible field says nothing and the note cannot guess. A removal (null) saysKEY edit removed(it falls back to the published patch or the default). HDR output ON adds(no HDR renderer)or(screen not HDR)fromHdrOutput.status. player_stat only forSim.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 callapply_dev_commanddirectly, 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 mostPatchToast.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 tunablePatchToast.HOLD_S(Tunables tab, Dev tools).
- Auto-arm + stable code.
- 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 sameapply_dev_commanddispatcher the Remote Console already drives (no logic duplicated) — reachable straight from the in-run pause menu (Debug Settings, dev-build-only viaBuildConfig.dev_tools(), same gate the whole panel already sits behind). Newdev_command_requested(cmd)signal, wired once inmain._readyalongside the pre-existingdimension_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 takessimtoo (wasqm-only) so toggle rows can read live state the same way graphics rows readQualityManager.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 onEnemyPool/inbible.jsonbut were never reachable via a story/dev spawn before (story mode never authored an encounter using them). - Known limit:
_dev_spawn_boss("warden")draws onesim.rngvalue (main._dev_spawn_boss→BossWarden.spawn(sim), which has no position param and picks its own spawn point fromsim.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/#survivabilitysections) getdisplay:nonefrom a STYLESHEET rule, so revealing one withel.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/nodesmoke. - 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 copyproject.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 usedtnotedt;TYPE_CUSTOMhas 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}createsrender/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 atframe_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 → oneperf_segevent (genericeventstable, JSONdata; queryjson_extract(data,'$.scenario_id'));status().perf_scenariodrives the panel table. Holds/hides are shared withtools/perf_probeviarender/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_msfromTelemetry.render_times(root viewport only, so not directly comparable withsamples.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 toTelemetry._sync_measuring()at teardown. Armwebs/zoneswith a 0 count is refused at begin (that layer never exists under paused spawns). Main forwardsrender_eventsto it because main drainssim.render_fx_eventsbefore 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 inHdrLook(render-only, need no run, Debug Settings parity rows “Colour Profile” / “SDR Tonemap” / “HDR Tonemap”; capscolor_profiles/tonemap_modes; flagslook:profile|tonemap_sdr|tonemap_hdr; all three incontrol/src/guard.jsSAFE_KINDS, look-only).{kind:"freeze_scene", on}(dev builds, guarded, console-only likeperf_scenario, not LAN-safe):render/freeze_scene.gdgates onmain._mode == "survival"(sim.rulesetis always SURVIVAL), builds the batch1_evolved + Warden + webs/zones/targets scene viaPerfRig.DevHold(shared withPerfScenario) plus its own held ring of real enemy types (FreezeScene.SWARM, step 3; PerfRig untouched), runs 3 s of sim time, thenmain._sim_freeze_reason()returns"freeze_scene"untilon:false; it reports throughmain._frozen_reason(). - P3 gamut swatch (HDR P3 plan Task 3, device proof).
{kind:"gamut_swatch", on, p3}(Dev builds only viamain._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 addsrender/gamut_swatch.gdas a direct child of main (the tonemapped world canvas): right column the P3 red/green primaries as extended linear sRGB, left column the same coloursmax(0)-clamped (what sRGB output does), so sRGB output shows identical pairs and P3 output a saturation gap only.p3writesViewport.output_gamut1/0 throughmain.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_childrenon a new run or the menu) itstree_exitingruns_gamut_swatch_release, which restores the gamut the first ON found, so P3 never leaks past the check. Caps:dev_commandslistsgamut_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 /personasstraight 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']));loadCapsrestores 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’scell_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_samplescomes from the response, never hard-coded. Learning on/off is the Settings-tab dev flaglearning_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_commandcallsReviewPrompt.native_request()(theDcStoreReviewsingleton) directly, bypassing the store-channel / iOS-macOS / once-per-build gates, and never writesMetaState(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 ...orNOT made (no DcStoreReview native side, or no active window scene)(a false is the dead-feature signal). No Debug Settings row (one-shot proof, likegamut_swatch); capsdev_commandslists 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_commandputs a freshBootSplash(ui/boot_splash.gd, the launch cover: the engine boot splash picture redrawn at the same fit plus the cyan loading shimmer) up forsecs(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 theBootSplash.*tunables (Visuals & FX, all local + live) are tuned by eye. Toast “Launch cover preview: N s”; capsdev_commandslists 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 writesreports/det-trace-{good,this}-<tick>.txtfull-value dumps todiff. 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 asenemieswithrngunchanged (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) flagsrngat 182. Read anenemies-only first divergence as “possibly a moved rng draw” and compare the detail dumps. A legit re-pin still goes throughbh-determinism-repin. - Determinism probe (2026-10-07, dev-infra audit Tier 0) —
{kind:"det_probe"}, start menu only.net/determinism_probe.gdrunsNetCoopRun.build_sim(1234, 2 pilots)for 3,600 ticks with scripted inputs, auto-picked level-ups anddev_invuln, recordingSim.net_checksum()every 600;main._det_probe_ticksteps it 60 ticks/frame on shipped default tunables + the bundled bible (Tunables.snapshot/reset, thenTunables.restore), refuses during a run and aborts (without restoring) if a run starts underneath it. The result lands instatus().det_probe(console card), a toast, and adet_probeevent (genericeventstable;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.
Console MCP (agents, 2026-10-08)
Section titled “Console MCP (agents, 2026-10-08)”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:truefor the raw blob),send_command,telemetry_query(one read-onlySELECT/WITH … SELECTagainst production telemetry D1, max 200 rows),ab_test. - The human-is-playing guard. The game reports
status.human = {input_age_ms, autopilot}:input_age_msis the time since the last real human input, stamped from the root window’swindow_inputsignal so an event a menu consumes still counts;-1means none since boot. A command kind outsideSAFE_KINDSis refused with HTTP 409{error:"guarded", reason}whileinput_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 iscontrol/src/guard.js, imported by both the Worker’s/cmdand the MCP, so they cannot disagree. The panel bypasses it withsource:'panel'(a human is at the console); the MCP never sends that. overrideetiquette.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 inreason. Never override past a refusal on your own judgement: report the refusal.- Reading
ab_test. It runs ≥3 ABBA rounds of onegraphicstoggle (3 s settle per side, 1 Hz fps samples,/watchevery 20 s), reading the toggle’s LIVE default first, and infinallyrestores the toggle and thenqm.auto(a consolegraphicspress 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.conclusiveis true only when the side means differ by more thansd_a + sd_b:conclusive:falsemeans noise, not “no effect”, so rerun with a longerseconds_per_side.scene_driftmeans the scene changed size under the test, so the delta may be the scene, not the toggle.insufficient_samplesmeans the device posted too few fresh status rows. Checkrestoredis all true after every run. - Status fields agents read.
humanandqm = {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
humanrefuses every guarded command asstatus_unknown, and a panel tab opened before the deploy must be reloaded (it lackssource:'panel').
Live SFX audition (Dev, 2026-10-14)
Section titled “Live SFX audition (Dev, 2026-10-14)”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 KVaud-<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. TheSFXbinding is the telemetry Worker’s existingCONFIGnamespace:aud-never overlaps the publishedsfx-<sha>sound-pack blobs orbuild-patches, and an audition can never put a TTL on a published blob. The client checks the file withwavProblem(same rules asRuntimeWav) 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_commandroutessfx_audition/sfx_audition_clear {key?}(no key = all) tomain.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 withRemoteSounds.verify(byte count, SHA-256,RuntimeWav.check: the same code as remote sound packs) and installs it withAudioManager.set_audition(key, stream), then previews it once (AudioManager.preview: the stream as sent on a priority pool voice, never the looped copy) unlessplay: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--waitsender sees the reason); on-device notesConsole: Audition <key> loading / on / failed (...)/ reverted. - Audition layer.
AudioManager._auditionis checked before the remote-pack_overridesin_stream_forand_slot_or_fallback, is set and cleared mid-run, and is never touched by the run-startset_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>_Nonly 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.jsSAFE_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 +
SFXKV binding). The device side needs a Dev install. Unverified on device (as of 2026-10-14): the download and runtimeAudioStreamWAVplayback have run headless on macOS 4.7 only; RuntimeWav fills the stream itself (noload_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_bosshands the director’s between-tick fx events toaudio.consumeat once and appends them tosim.render_fx_events; before, the next tick’sfx_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_commandroutesautopilottomain._autopilot_command(cmd, dev)before the sim guard; Dev builds only (store builds return false, no note). ON sets_autothrough_set_autoand flies the run’s own attract AI (auto_player, built by_new_runfor every run: aSkilledAutoPlayer, 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 readsConsole: 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_runkeeps those human-flown on purpose, lesson gates and dialogue wait for a human). Lift the story refusal inmain._autopilot_refusalif ever needed. - Telemetry marking. Any change it makes sets
main._console_autopilot_run(per run), which addsconsole_autopilot: truetorun_end, the run body’s repro block andruns.timeline_json(the Worker drops the unknown run field and stores the timeline verbatim, no migration).tools/economy_sim/human_targets.pydrops 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’sauto(which the Worker keeps asruns.is_auto), the run body’sis_autoand the autopilot identity (auto_profileetc.) all report a human run, sois_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) andReplayRecorder.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 theonsegments out, or skip a replay whose list is non-empty. - Guard.
autopilotis NOT incontrol/src/guard.jsSAFE_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 forhuman_playinglike any run-touching kind. When Chris asks an agent for it, sendoverride:truewith his words inreason. - 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 MetalSubViewport(tools/story_board/capture.gd/capture.tscn), screenshotting the actual scene at each beat’s trigger tick; (3) it writesout/storyboard.json— one beat card persayevent (tools/story_board/beat_log.gd’sBeatLog.build(), pure/unit-tested: id, line count, gap to next beat, nearest trigger, and refs into the code/data that control it) plus the matchingout/beat-*.pngscreenshots; (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 totools/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 envSTORY_REPLAY, defaultuser://replays/tutorial.json). ⚠️ Any windowedgodot --path .invocation can silently corruptproject.godot(seedocs/architecture/godot-gotchas.md) —git diff project.godotright after andgit checkout -- project.godotif it shows changed, before it lands in a commit. - Serve + review:
python3 tools/story_board/server.py, then openhttp://localhost:8081/tools/story_board/index.html(stdlib-only server, same convention astools/sound_map/server.py— serves the repo statically fromREPO_ROOTand handlesPOST /story_board/save).
- Capture (WINDOWED — MultiMesh colours are unreadable headless, same constraint as the UI-sweep
harness):
- Testable core:
renderBeatCard(beat)andcollectPins(state)insrc/app.jsare pure functions, unit-tested via Node’s built-innode:test(node --test tools/story_board/tests/app.test.js) — the same guardedmodule.exports/typeof windowbootstrap pattern astools/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.shre-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: channeldev, @dev patches, console auto-arm, Debug Settings; testers only, Chris’s call), linked from the site’s/play/page.--devembeds the console’sCONTROL_KEYin the public pck: redeploy without--devand rotate the key before the demo goes properly public. The rootexport_presets.cfgis gitignored/local-only, so the script itself asserts the Web preset excludesaudio/music/ audio/candidates/ tools/ tests/ telemetry/ addons/gut/ marketing/ concept-art/ docs-site/ docs/ perf-data/and fails ifindex.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 deploydoes NOT honor.assetsignoreand 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 (onlyindex.html/404.html/favicon.svg/bible/), or physically move internal files out first. A file removed from a later deploy keeps serving a stale200from 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).
Documentation site
Section titled “Documentation site”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.
Build channels (2026-09-28)
Section titled “Build channels (2026-09-28)”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 fortvOS-Dev/iOS-Dev(preset restored by trap,check_bakedskipped; first-use stalls expected, never for release), for confirming the A17 iPhone baker draw bug (godot#124088). - One build number:
scripts/bump-build.shis the only writer; store build ==Sim_Const.BUILD;tests/test_release_presets.gdenforces it. Onmain/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.shtags every uploadstore-N. Mergerelease/*back to main before main’s next release, or both will try the same BUILD (release.sh’sstore-Npre-check fails fast). - Channel:
BuildChannel.current()(dev/web/testflight/appstore, failure →appstore) rides on every telemetry/feedback row (channel,channel_probecolumns) andGET /config. Filter real players withWHERE 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) andbuild-macos.sh(macOS) runasc.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. Raiseapplication/short_versionin all seven Apple presets together (apple_short_versionrefuses a mismatch).seed_build_dirs(env.sh) copies the gitignoredplatform/tvos/build/+build-ios/(ExportOptions.plist, thedummy.mmsave bridge) from the main checkout when a worktree lacks them, sorelease/<N>worktrees release cleanly.altool_runkeeps 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-Devlive inplatform/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 viabump-build.sh. Toolchain: JDK 17 (brew openjdk@17, set as Godot’sjava_sdk_path), SDK at~/Library/Android/sdk(platform 36, build-tools 36.1.0, NDK 29). Release key:~/.android-keys/dark-cosmos-release.keystore, aliasdarkcosmos, passwordDARK_COSMOS_ANDROID_KEYSTORE_PASSWORD, SHA-25625:FB:04:04:…:A2:17; backed up (file + password) in 1Passwordachris. 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 skillbh-android-tester-build(scripts/release/android-tester-build.sh). Open:BuildChannellabels any non-dev Android buildappstore. - macOS / Mac App Store (2026-10-04): preset
macOSinplatform/tvos/export_presets.cfg(universal arm64+x86_64, same bundle id as iOS/tvOS = Universal Purchase). That project’srendering_method.macos="mobile"gives the Mac Forward Mobile on Metal + HDR glow; the root project’s localmacOSpreset is gl_compatibility (no glow) and is for quick dev runs only.scripts/release/build-macos.shexports windowed (shader baker), adds the Game Mode/controller plist keys, embeds the MAC_APP_STORE profile (~/.appstoreconnect/profiles/DarkCosmos_MacAppStore.provisionprofile), signs withApple Distribution+platform/tvos/patches/macos.entitlements(App Sandbox + network client),productbuilds a pkg signed by3rd Party Mac Developer Installer, validates and uploads via altool (NO_UPLOAD=1stops after validate). Sandboxed saves live in~/Library/Containers/digital.lumara.bulletheaven/Data/Library/Application Support/Godot/app_userdata/Dark Cosmos/. Not yet inrelease.sh. Open:BuildChannellabels Mac TestFlight buildsappstore(the Mac receipt isContents/_MASReceipt/receiptfor 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 parentdigital.lumara.bulletheaven+ clipdigital.lumara.bulletheaven.Clip; Dev parentdigital.lumara.bulletheaven.dev+ clipdigital.lumara.bulletheaven.dev.Clip(“Dark Cosmos Dev”). One App Group per channel,group.$(BH_APP_ID):group.digital.lumara.bulletheaven(store) andgroup.digital.lumara.bulletheaven.dev(Dev); parent and clip both carry it (the clip writesclip_rewards.jsonthere, the full app reads it for the Beta-skin grant). PresetsiOS-Clip/iOS-Clip-Dev. Prerequisite (developer portal, by hand: Xcode with the ASC API key cannot add it, “Authentication failed”): registergroup.digital.lumara.bulletheavenandgroup.digital.lumara.bulletheaven.dev; enable App Groups ondigital.lumara.bulletheaven,.Clip,.devand.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 ownplatform/tvos/build-ios-clip/shell, soinstall-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 bundledigital.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.shexports theiOS-Clippack, checks the bake andDcAppClip, adds theDarkCosmosCliptarget (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 runsscripts/release/check-clip-size.mjs --reporton “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--apppre-check on the built bundle. WithoutBH_WITH_CLIPthe 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.shhardcodes 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 fromxcrun devicectl list devices(rotated + fixed 2026-09-30 in1203123e; the old July ATV id had gone dead).
Internet co-op relay Worker (2026-10-14)
Section titled “Internet co-op relay Worker (2026-10-14)”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) andrelay/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_URLpoints 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 throughwrangler dev --local; not inscripts/gate.sh). Post-deploy, the same script withRELAY_URL=wss://bullet-heaven-relay.chris-allen-06f.workers.dev … 3600 200proves a lone host survives 3+ min idle on the real edge. - The player gate is a remote-config
featureskey, TestFlight-only until 1.2:docs/architecture/remote-config.md§ Features.
Telemetry v2 (2026-09-29)
Section titled “Telemetry v2 (2026-09-29)”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=06f5622d4192dcefefbe0da75d766bfcnpx 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 whynpx 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 launchnpx 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.godotcomments must use;, not#: with#, Godot swallowed the[sentry]section that follows.always_track_call_stacks.tvos=falsealways, and.ios=falsewhile there is no DSN, so dormant Sentry costs no script time. When the DSN is added, delete the.iosoverride (G7);scripts/release/sentry-guard.sh dsn-vs-override(run bybuild-ios.sh) andtests/test_release_presets.gdboth refuse a DSN with the override still present.- Dev iPhone installs need a full iOS export first. A
--export-packbuild only links libsentry ifbuild-ios/last came from a full export with the addon fetched;sentry-guard.sh ios-shell(run byinstall-dev.sh) stops with the exact commands (full export, thencp patches/ios-BulletHeaven-Info.plist build-ios/BulletHeaven/BulletHeaven-Info.plist, then re-runinstall-dev.sh). - Checking out a commit from before Task 9 (
45dc6cc) makes git treatplatform/tvos/addonsas a symlink again: the ignoredaddons/sentry/is deleted andsentry_dsn.jsonis no longer ignored. Re-runfetch-sentry.shafter 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.
Replay auto-upload (Dev builds)
Section titled “Replay auto-upload (Dev builds)”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 viarequest_raw; deleted on200 {"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
seedint /fresh_savebool and seed == query seed (400), 200 objects per UTC day (429, KVCONFIGkeyreplay_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 writestickslast), 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 atreplays/<build>/<install>/<unix>-<seed>.json.gzin R2bh-replays(bindingREPLAYS), custom metadata build/install/unix/seed/ticks. An existing key answers 200dup. - Retention: R2 lifecycle rule
expire-365deletes 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 inRETENTION_TABLES(D1 only). - Mac:
scripts/replays-pull.shlistsbh-replays(Cloudflare REST API; wrangler has no object list) and downloads every object not already in gitignoredreplays/(same<build>/<install>/layout, gunzipped), printing tick count and duration. Hand-pulled pre-upload copies live inreplays/hand-pulled/. Compare against a bot run withtools/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
nmof 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:
atosatMain::iteration + 0x40givesMain::iteration() (in BulletHeaven) + 64; a mid-function address in the GDScript VM givesGDScriptFunction::call(GDScriptInstance*, Variant const**, int, Callable::CallError&, GDScriptFunction::CallState*) (in BulletHeaven) + 256. No file:line on any engine frame. dwarfdump --debug-infoon each dSYM lists exactly two compile units, both Xcode-shell code (dummy.cpp/dummy.mmand Xcode’s generatedGeneratedAssetSymbols.swift). No engine source has DWARF, on either platform.- Upload path: both
ExportOptions.plistfiles setuploadSymbols = true, and each exportedipa-out/BulletHeaven.ipacontainsSymbols/<UUID>.symbolswith the archive’s UUID (checked withunzip -l).altool --upload-appsends 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
.symbolsresolve names locally withatos. [Likely] that Organizer shows the sameFunction + offsetframes, 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.gdfile 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.zipunder4.7.1.stable, and the fork’stvos.zipunder4.7.devperbh-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>.