Skip to content

General performance review (2026-09-29)

Scope: frame rate, frame-time consistency and CPU/GPU cost of Dark Cosmos on Apple TV, iPhone, iPad and web, from the 14-day telemetry dump on branch perf-data/2026-09-29 (BUILD 188-209 plus dev build 9001), static reading of the code on that branch, and headless / software-rendered measurements in a Linux container. Shader-compile stalls (fixed in BUILD 209) are out of scope, except where they confound the data.

Confidence tags: [Certain] = shown directly by data or code. [Likely] = the best explanation of several pieces of evidence, not proven on device. [Guessing] = plausible and worth testing, with little direct evidence.

Bottom line:

  • The Apple TV is GPU/fill-bound in every state. render_cpu_ms is 0.7-2.2 ms, and our _process scripts measure 1-3.5 ms per frame in a software-rendered run of the real game.
  • Sim ticks are not free either (2.7-4.4 ms max per tick, 60 per second).
  • Enemy count is not what drives the frame rate. An empty arena already costs about 24 ms per frame on the 50 Hz ATV, against a 20 ms budget.

The five things that matter most, in order:

  1. Game-over screen (23 fps on every build since the 2026-09-26 glass redesign; 48 fps on the old panel). Root-caused. [Certain for the cause, Likely for the size of the win]
    • The results card is a live-frosted GlassPanel (ui/results_panel.gd:78). It copies the whole HDR screen and rebuilds its mip chain every frame, while the full world (nebula SubViewport, glow, lights, swarm) keeps rendering underneath.
    • This is the same defect the project already fixed on the level-up screen with GlassPanel.snapshot_backdrop() (ui/theme/glass_panel.gd:14-16). Results never got that fix.
    • In a software-rendered A/B, switching the card to the snapshot saves 16 of 60 ms and keeps the look. Stopping the nebula behind results saves another 8 ms. Effort: small.
  2. The Apple TV is probably rendering at native 4K. [Likely]
    • Stock Godot sets contentScaleFactor = UIScreen.scale, which is 2.0 on a 4K Apple TV. The project uses canvas_items stretch with HDR 2D on.
    • The same A12 chip in an iPhone XS (2.7 MP) holds 60 fps on this code, while the ATV cannot hold 50 with zero enemies.
    • First add the drawable size to the launch event. If it is 4K, render at 1080p on TV (a one-line fork change, or a project-side content scale). That is about 4x less work in every full-screen pass: the most likely route to a steady 50 fps with bloom kept on.
  3. Bloom + HDR + point lights are the measured cost. [Certain for that build]
    • The only on-device tier A/B (dev build 9001’s perf bench) went from 35-38 fps at L0 to the 50 fps cap at L1 (bloom, HDR target and lights off) at every enemy count.
    • ADAPTIVE_QUALITY is still false, so no real session has used the ladder. The ladder also cannot help the game-over screen (it stops ticking at sim.game_over).
    • Make bloom cheaper, and at the new resolution, before letting the ladder shed it. Bloom is the neon look.
  4. Boss fights (16 fps p50) and late runs (9-18 fps with fewer than 40 enemies) are the worst gameplay frames. [Likely]
    • Cause: boss visuals (antialiased arcs and lines redrawn every frame, big boss PointLight2Ds), per-hit FX (the aim shot’s burst rings top the hitch tags), and physics catch-up.
    • Godot’s default 8 catch-up steps per frame means 3-5 sim ticks per rendered frame at 12-18 fps. Cap it at 3 on tvOS.
  5. CPU hygiene (smaller, safe, determinism-neutral):
    • MultiMesh buffers are reallocated whenever a pool’s size changes. That is CPU churn, not a GPU stall on stock 4.7.1.
    • FX pools use one material per node, which defeats batching.
    • The hidden save-debug label does file I/O every frame.
    • In attract mode the autopilot planner costs up to 2.6 ms per tick headless: more than the sim.

Adaptive quality: it never changed tier on device outside the 9001 bench. Turned on, it would have held 50 fps in play at L1, at the cost of the bloom look. Fixes #1 and #2 are the better first moves.

Environment: Godot 4.7.1 installed and ran here. GUT 335/335 scripts / 3,337 tests passed. Metal could not be reproduced. The software Vulkan runs are a proxy for GPU cost, and every on-device GPU claim is inferred (gpu_ms is NULL on Metal). Tags: [Certain] / [Likely] / [Guessing] throughout.

Branch: perf-review/2026-09-29. Analysis scripts and raw results are in perf-data/2026-09-29/analysis/. No game code was changed.

table rows notes
samples 14,202 5 s perf windows, 2026-09-23 21:10 to 2026-09-29 16:52 UTC; 8,362 from tvOS/AppleTV11,1
hitches 784 one row per stalled frame (>= 60 ms); BUILD 197+
runs 76 banked runs; timeline_json from BUILD 206+
events 1,522 telemetry v2 envelopes (BUILD 206+)

Per-build sample counts, sessions and play/frozen split: python3 perf-data/2026-09-29/analysis/analyze.py inventory. BUILD 209 on Apple TV has 496 samples in 4 sessions (3 autopilot sessions plus the long 16:40-16:52 run 9594caba, which the dump’s README calls the human run, but whose samples and runs row both say auto=1. Its sim_ms may therefore include the autopilot planner; see §2.7).

1.2 How to read the columns (important caveats)

Section titled “1.2 How to read the columns (important caveats)”
  • render_ms (samples) and proc_ms (hitches) are Performance.TIME_PROCESS. In Godot 4.7 that is the per-second maximum of the time spent in MainLoop::process plus RenderingServer::sync() and draw(). When the GPU is the bottleneck, the CPU blocks inside draw() waiting for a drawable, so proc ~= frame means “GPU-bound or script-bound”, not “script-bound”. [Certain] (engine main loop, docs/architecture/dev-tools.md § Telemetry v2).
  • sim_ms is TIME_PHYSICS_PROCESS: the max single physics step in the last second, read once per 5 s sample (so it covers 1 of the sample’s 5 seconds; correction 2026-10-07). Since 2026-10-07 samples also carry tick_ms / tick_max_ms / ticks, the mean and worst measured sim.tick over the whole window; use those for sim cost. It includes main._physics_process (input, the SkilledAutoPlayer planner in attract mode, sim.tick_single). It is not per frame: Godot runs 60 physics steps per second whatever the frame rate, so a 3.5 ms tick costs about 210 ms of every wall-clock second (about 4.2 ms per frame at 50 fps, and more per frame as fps drops). [Certain]
  • render_cpu_ms (BUILD 206+) is the CPU time spent recording render commands. It is 0.5-3 ms everywhere, so render-command CPU is not the problem. [Certain]
  • gpu_ms is NULL on Metal (the tvOS fork has no timestamp queries). There is no direct GPU measurement on any Apple device. GPU cost is inferred from frame - (CPU parts). [Certain]
  • Samples carry no state. I label them play when run_time advanced since the previous sample of the session and frozen otherwise (game-over, pause, level-up, codex card, warp). A second labelling joins each sample to the nearest hitch row (<= 6 s) and takes its state, which covers only stall-prone windows.
  • The Apple TV is on a 50 Hz panel (build 9001 caps at exactly 50 fps). With vsync, frame times quantise to 20 / 40 / 60 ms, so a GPU frame of 21-25 ms shows up as about 42-46 fps average, and 41-45 ms as about 23 fps.
  • quality is 0 in every non-9001 sample and every hitch row: ADAPTIVE_QUALITY := false (main.gd:152), so the ladder never moved on device outside the dev perf bench. [Certain]

1.3 Headless and software-rendered runs in this container

Section titled “1.3 Headless and software-rendered runs in this container”
  • Godot 4.7.1 stable (official Linux x86_64 build) installed. godot --headless --import OK. scripts/check-test-count.sh: 335/335 scripts, 3,337/3,337 tests passing (environment sane).
  • Metal cannot be reproduced. I installed Mesa lavapipe (software Vulkan) and ran the real main.tscn under Xvfb with --rendering-method mobile. That gives real draw-call counts and real script timings for _process / _physics_process. Its frame times are a software-raster proxy only and are not comparable to the A12 in absolute terms.
  • Tools written for this review (analysis only, not game code): perf-data/2026-09-29/analysis/godot/sim_profile.gd (+ generated profiled_sim.gd, a phase-timed copy of Sim.tick) and frame_probe.gd/.tscn (boots main, attract autopilot, forced death, per-frame timings, hide-one-node breakdown, alternating A/B toggles on the results screen).

2.1 Where Apple TV frame time goes, by state

Section titled “2.1 Where Apple TV frame time goes, by state”

ATV samples from BUILD 202-209, labelled with the state of the nearest hitch row (analyze.py bystate; this covers only windows that had a stall nearby):

state n fps p50 fps p10 frame p50 (ms) proc max p50 sim max p50 render_cpu p50 draws p50 prims p50 enemies p50
gameover 302 23 21 43.5 43.7 0.2 2.2 131 3,828 33
play 181 35 23 28.6 29.9 3.7 0.7 139 6,628 36
pause 37 30 29 33.3 33.6 0.2 1.7 61 1,144 29
levelup 18 32 29 31.2 31.6 0.2 1.9 163 4,254 52
boss (play) 21 16 13 62.5 57.9 5.7 0.8 217 15,834 23

Using the play/frozen split over all ATV builds instead: play p50 44 fps (n=2,362); frozen p50 36 fps (n=5,942). For BUILD 209 only: play 35 fps (n=207), frozen 23 fps (n=285).

  • Every state is GPU-bound or wait-bound, not render-CPU-bound. render_cpu_ms is 0.7-2.2 ms while proc ~= frame in every state. The frozen states run no sim (sim_ms 0.2) and have few draws (61-163), yet run at 23-32 fps. [Certain] for “not render CPU, not sim”. [Likely] for “GPU fill / bandwidth”, because script _process measures 2-3.5 ms per frame in the software-rendered probe (§2.7), far below the 23-43 ms frames.
  • Paused states are slower than live play with 150+ enemies. Pause (30 fps) and level-up (32 fps) run below play at 150-200 enemies (44-48 fps, table 2.2). The full-screen passes those screens add cost more than the swarm does. [Certain] (data).

2.2 Apple TV play frame time does not scale with enemies

Section titled “2.2 Apple TV play frame time does not scale with enemies”

ATV PLAY samples, all builds pooled (analyze.py knees):

enemies n fps p50 fps p10 frame p50 draws p50 % windows < 30 fps
0-20 808 43 31 23.3 83 9%
20-40 562 43 29 23.3 122 11%
40-60 358 45 33 22.2 120 6%
80-100 122 45 31 22.2 136 7%
140-160 79 46 35 21.7 185 3%
180-200 67 44 19 22.7 131 16%
200+ 19 39 14 25.6 276 37%
  • The frame is about 22 ms at every enemy count from 0 to 200, and 24.4 ms (41 fps) with zero enemies (n=108). A fixed per-frame cost that exists before any swarm is drawn is the main reason the 50 Hz ATV misses its 20 ms budget. [Certain]
  • Draw calls track fps weakly: 0-50 draws give 48 fps, 250-400 draws give 36-38 fps. That split is confounded with boss fights and late runs (webs, FX). [Certain] for the correlation, [Guessing] for how much draws cause.
  • Webs 100+ gives 38 fps p50 (p10 16) against 45 with no webs. Zones are rare in this data (13 windows). [Certain]
  • Per build, the same enemy bucket gives about 40-47 fps on every build from 188 to 208 (atv_builds). There is no structural per-build regression in play. BUILD 209 looks worse (35 fps p50) because its sessions include the long late-run / boss session 9594 (below), not because the code got slower. [Likely]: only 4 sessions on 209.

2.3 Same A12 chip: iPhone XS holds 60 fps, the Apple TV does not

Section titled “2.3 Same A12 chip: iPhone XS holds 60 fps, the Apple TV does not”
device GPU native drawable (assumed) play n fps p50 fps p10 frame p50 draws p50 0-enemy fps
tvOS/AppleTV11,1 A12 3840x2160? (8.3 MP) 2,254 44 31 22.7 118 41 (n=108)
iOS/iPhone11,2 (XS) A12 1125x2436 (2.7 MP) 535 60 (vsync cap) 40 16.7 185 60
iOS/iPhone16,2 A17 Pro 1290x2796 179 118 86 8.5 161 120
iOS/iPad13,8 M1 2048x2732 478 120 101 8.3 173 120

The same code on the same GPU family holds the 60 Hz cap on an iPhone XS with more draw calls. The Apple TV has active cooling and cannot hold 50 Hz even with an empty arena. Two differences fit this:

  1. Render resolution. Stock Godot’s Apple Embedded view sets contentScaleFactor = [UIScreen mainScreen].scale (drivers/apple_embedded/godot_view_apple_embedded.mm:127, 4.7.1-stable). On a 4K Apple TV, UIKit reports 1920x1080 points at scale 2, so the drawable would be 3840x2160: about 3x the XS’s pixels. platform/tvos/project.godot:11-14 has stretch/mode="canvas_items" with no resolution cap, and viewport/hdr_2d=true makes every pixel an RGBA16F read/write. [Likely]: the fork (4.7.dev.custom_build (tvOS)) was not available to check, and telemetry does not record the drawable size.
  2. Engine build. tvOS runs the custom fork (GodotEngine/4.7.dev.custom_build (tvOS), 8,362 samples), while iOS runs stock 4.7.1.stable.official (5,629 samples). A slower Metal path in the fork would look the same. [Guessing]

2.4 The quality ladder was measured once, and L1 alone reached 50 fps

Section titled “2.4 The quality ladder was measured once, and L1 alone reached 50 fps”

Build 9001 (2026-09-23 21:10-21:32) was the DevFlags.perf_bench mode. It forces tiers L0 to L3 in 45 s dwells on an invulnerable autopilot run, across 4 sessions:

tier enemies n fps p50 fps p10 frame p50
L0 (full) 0-50 31 37 31 27.0
L0 50-120 18 35 30 28.6
L0 120-400 12 38 35 26.3
L1 (no bloom, no HDR 2D target, no point lights) 0-50 41 50 49 20.0
L1 50-120 22 50 40 20.0
L2 0-120 63 50 50 20.0
L3 0-120 63 50 50 20.0
  • Dropping bloom + the HDR 2D target + the hero PointLight2Ds (QualityManager L1: bloom_on, hdr_on, hero_lights_on, render/quality_manager.gd:130-145) takes the ATV from 35-38 fps to the 50 fps vsync cap at every load tested. Those three effects cost at least 7-8 ms of a 27 ms frame. The true cost is hidden by the cap. [Certain] for that build’s code. The dump holds no later bench.
  • Because ADAPTIVE_QUALITY := false (main.gd:152), no real session has ever used L1. The ladder also stops ticking once sim.game_over is set (main.gd:1750 returns before the quality_manager.tick at main.gd:1774-1775). So even when switched on it could not rescue the results screen, and it would keep whatever tier play last reached. [Certain]

2.5 The game-over screen (18-23 fps) — root cause

Section titled “2.5 The game-over screen (18-23 fps) — root cause”

The data:

  • The BUILD 188 session 99c069ab sat on its results screen for 2.7 h at 48 fps with 52 draws (1,970 frozen samples, run ended at 54 s; that build had the pre-glass results panel). BUILD 190-193 frozen stretches ran at 35-49 fps.
  • From BUILD 202 (the 2026-09-26 glass redesign of ui/results_panel.gd shipped in between), every long ATV game-over stretch runs at 23 fps: proc 43-44 ms, render_cpu 1-3 ms, draws 104-232, sim_ms 0.2. That covers sessions 965d9abc, 5f1d4575, 336bc880 and 9594caba (the 16:50 run). Hitches in gameover state: 246 rows, frame p50 65 ms, draws p50 131. Opening the meta shop from results (336bc880, 16:19) drops it to 17-18 fps (draws 193, proc 55 ms). [Certain] (data).
  • iOS devices show the same pattern more mildly: iPad 94-96 fps frozen vs 120 in play, and iPhone XS 48 fps on a BUILD 201 game-over stretch.

The code: the results card is a live-frosted GlassPanel: card.frosted = true # live frost: the world still drifts behind the death screen at ui/results_panel.gd:78. Its shader ui/theme/glass_frost.gdshader:5,13 samples hint_screen_texture, filter_linear_mipmap at textureLod(..., 4.3). That forces Godot to copy the whole HDR back buffer and build its mip chain every frame. The project already diagnosed and fixed exactly this for the level-up screen: ui/theme/glass_panel.gd:14-16 says “BUILD 188 Apple TV: 24-31 fps with the live frost on the level-up screen”, and the fix was GlassPanel.snapshot_backdrop() (glass_panel.gd:111), a one-time 1/8 + 1/4-res blurred copy that level-up (ui/level_up_panel.gd:284) and pause (ui/pause_menu.gd:66) use. The results screen never got the snapshot. [Certain] (code).

Behind the card, the world keeps rendering at full cost on the results screen:

  • the nebula SubViewport is UPDATE_ALWAYS at half resolution, plus its full-screen upscale quad (render/nebula_bg.gd:18-48);
  • 2D glow + tonemap + HDR stay on;
  • the hero PointLight2D is updated before the game-over return (main.gd:1733-1734);
  • the enemy swarm is re-synced every frame (main.gd:1747-1748);
  • the arena background calls queue_redraw() every frame (render/arena_background.gd:113-118);
  • a full-screen dim ColorRect sits on top (ui/results_panel.gd:65-70).

If the player died with a Graviton or boss alive, the gravity lens (main.gd:1902) and boss lights (main.gd:1927-1934) stay frozen on as well. [Certain] (code).

Results-screen A/B measurements (software renderer, alternating toggles): see §2.7.

2.6 Boss fights and late runs: 9-18 fps with few enemies

Section titled “2.6 Boss fights and late runs: 9-18 fps with few enemies”

Session 9594caba (BUILD 209, 16:40-16:50, 461 s, level 35; weapons blaster + nova + orbit; picks include split, pierce x2, crit x2, fire-rate x2 and 20+ crystal picks):

  • Early (0-130 s, 60-200 enemies): 33-42 fps.
  • Warden fight (203-329 s, 8-42 enemies): 12-18 fps, proc 54-78 ms, draws 162-320, prims 10-25k, 36-49 hitch frames per 5 s window.
  • After the boss (340-461 s, webs up to 400): 9-18 fps, proc up to 115 ms, sim_ms max up to 16 ms.

Hitches labelled boss on the ATV: 64 rows, proc p50 54 ms, draws p50 232. The recent-event tags are dominated by burst / dmgnum / blaster_fire / aim_fire (the aim shot’s impact burst, sim/elemental_system.gd:127, drawn as a 64-segment antialiased ring pair by FxManager._spawn_ring, pool 24). [Certain] (data).

What this means: late-run cost is driven by boss-state rendering and weapon-hit FX volume, not by enemy count. On the render side, render/boss_renderer.gd issues about 15-25 antialiased draw_arc/draw_line calls per frame (lines 58-148), plus 3 per missile, plus 3 boss PointLight2Ds at BOSS_RADIUS (render/hero_lights.gd). [Likely]: no per-node GPU timing exists on device.

2.7 Headless and software-renderer measurements (this container)

Section titled “2.7 Headless and software-renderer measurements (this container)”

Software-rendered frame probe (frame_probe.tscn; lavapipe on Xvfb, Mobile renderer, 1280x720, real main.tscn, attract autopilot, invulnerable, then forced death). Absolute frame times are CPU-rasterised and not comparable to the A12. Relative costs and all CPU timings are real.

Script and physics cost per rendered frame, measured with hook nodes at process priority ±1e6 around every other _process / _physics_process callback:

phase run time frame p50 all _process scripts p50 / p90 physics per frame p50 / p90 draws enemies
play 18 s 80 ms 3.3 / 3.8 ms 17.9 / 23.2 ms 175 173
play 71 s 60 ms 2.1 / 2.6 ms 8.0 / 12.5 ms 55 46
play 186 s 77 ms 2.1 / 2.5 ms 12.7 / 24.2 ms 280 30
results screen 217 s 60 ms 1.2 / 1.3 ms 0.04 ms 85 1
  • Our _process scripts cost 1-3.5 ms per frame. Physics costs 8-24 ms per frame, because the slow software frame makes Godot run several 60 Hz ticks per frame. That is exactly the catch-up multiplier in #5. [Certain] (this CPU). Scale to the A12 unknown.
  • On the results screen, script cost is 1.2 ms per frame. The 43 ms device frame is therefore GPU work. [Certain] for script; [Likely] for GPU.

Results-screen A/B: alternating toggles, 4 rounds × 40 frames each, mean saving. Two independent runs:

toggle on the results screen run 1 saves run 2 saves draws saved
hide the whole ResultsPanel 22.7 ms 22.0 ms 56-59
card frost off (clear glass) 15.5 ms 16.2 ms 0
card frost = one-shot snapshot (level-up style) n/a 16.1 ms 0
hide the nebula (NebulaBg.set_enabled(false)) 8.0 ms 8.8 ms 1-2
glow off 4.5 ms 4.7 ms 0
full-screen dim ColorRect hidden 4.3 ms¹ 2.4 ms 1
hero lights hidden 2.1 ms 1.2 ms 0
frozen enemy swarm hidden 1.3 ms 0.4 ms 3-4

¹ run 1 used the hide-one-node breakdown (±3 ms noise), not the A/B.

Out of a 60 ms software frame, the live frost is about 27% and the whole panel about 37%. The snapshot keeps the frosted look and removes the per-frame cost. [Certain] in software. The device share is [Likely] similar or larger: the A12 has to copy and mip an RGBA16F target at native resolution every frame.

Headless sim profile (sim_profile.gd, phase-timed copy of Sim.tick, seed 1234, invulnerable, SkilledAutoPlayer input, weapon grants picked first):

Mean µs per tick per 30 s window, on this container’s x86 vCPU (the A12 is slower; sim_ms on the ATV reads 2.7-4.4 ms max per tick). The checksum matches a plain Sim run (--plain, 141779504 at 5,400 ticks), and timer overhead is about 1.5%.

game time level / weapons sim µs/tick mean (p99, max) top phases (µs/tick) enemies enemy bullets AI planner µs/tick
30 s 3 / 1 1,873 (3,082, 8,734) enemy move 846, status/zones/webs 372, enemy attacks 336, hash 121, collisions 112 140 15 620
60 s 5 / 2 1,121 (2,153, 4,189) enemy move 456, attacks 224, status 198 70 68 960
150 s 12 / 5 730 (1,231, 1,633) enemy move 237, attacks 157, status 118, proj move 56 39 100 1,417
210 s 15 / 6 255 (659, 1,025) enemy move 65, attacks 54, weapons 30 7 17 252
300 s 19 / 6 731 (1,211, 1,926) enemy move 214, attacks 137, status 100, proj move 100 29 183 2,575
360 s 23 / 6 772 (1,193, 1,503) enemy move 231, attacks 154, collisions 85 33 151 1,745
  • The sim tick scales with live enemies (about 6 µs per enemy per tick in movement alone), not with run progression. No phase blows up late, and weapons cost 2-9 µs each. This run had no webs (no spiders spawned), so web cost is untested headless. [Certain] (this seed and harness).
  • The attract-mode AI planner (SkilledAutoPlayer, used by main.gd:1303 for every autopilot run) costs more than the sim late in a run, and grows with enemy bullets. It is 0.25 ms per tick with 17 bullets and 2.6 ms per tick with 183. The cost is the DangerLanes escape and warp search: 16 directions × escape rings × is_clear over every lane (ai/skilled_auto_player.gd:108-191, ai/danger_lanes.gd). On device it runs inside _physics_process, 60 times a second, so it inflates sim_ms and main-thread load on every auto=1 session, including 3 of the 4 BUILD 209 sessions. [Certain] (headless). The device share is [Likely].
device state 5 s windows % windows with a >= 60 ms frame worst-frame p50 worst-frame p90
tvOS/AppleTV11,1 play 1,231 22% 51 ms 74 ms
tvOS/AppleTV11,1 frozen 2,679 56% 61 ms 64 ms
iOS/iPhone11,2 play 401 12% 25 ms 73 ms
iOS/iPad13,8 play 733 3% 14 ms 23 ms
iOS/iPhone16,2 play 135 1% 15 ms 18 ms
web play 208 10% 21 ms 35 ms
  • Uneven pacing on the ATV is mostly quantisation, not stalls. A typical ATV play window’s worst frame is 51 ms, which is 2.5 vsyncs at 50 Hz. On game-over, frames alternate 40/60 ms, so 56% of windows contain a 60 ms frame. Getting under a GPU cost of 20 ms removes both. [Likely]
  • Physics catch-up amplifies slow frames. physics/common/max_physics_steps_per_frame is unset (default 8, main/main.cpp:2248). At 12 fps the sim runs 5 ticks per rendered frame. At the 4-6 ms max tick seen late in 9594caba, that is 20-30 ms of each frame spent in sim, on top of a GPU-bound frame. [Certain] (engine default + telemetry). How much it adds on device depends on CPU/GPU overlap. [Likely]
  • Node counts don’t leak. Nodes rise slowly (0-80 per minute, max 1,452 in play). The 1,760-1,802 counts belong to results + shop screens (analyze.py nodes). [Certain]

Tags in the recent list of non-pipeline hitches (784 rows): blaster_fire 317, aim_fire 316, dmgnum 301, burst 278, death 217, enemy_ranged_fire 182, nova 106, drone_pulse 67, state:levelup 62, reaction:BOMB 24, reaction:BOSS DOWN 18, boss_missile_impact 19. There is no single “new effect” culprit left after BUILD 209. Only 2 of 204 BUILD 209 ATV hitches had pipelines > 0. Hitches are sustained-load frames (fire + hit FX), not one-off stalls. [Certain]

Ordered by expected player-visible gain per unit of effort and risk. The neon look is the priority: every item says what it does to the look. Nothing here touches /sim state or rng order unless stated, so the determinism baselines (tests/test_determinism*.gd) are unaffected by construction.

#1 Results screen: snapshot frost instead of live frost, and stop the world behind it [Likely root cause; high confidence]

Section titled “#1 Results screen: snapshot frost instead of live frost, and stop the world behind it [Likely root cause; high confidence]”
  • Evidence:
    • Game-over runs at 23 fps (n=302 labelled samples) against 48 fps on the pre-glass results screen (BUILD 188, n=1,970), §2.5.
    • ui/results_panel.gd:78 sets frosted = true. ui/theme/glass_frost.gdshader:5,13 reads the screen with mipmaps every frame.
    • The same defect on level-up was measured at 24-31 fps and fixed with GlassPanel.snapshot_backdrop() (ui/theme/glass_panel.gd:14-16,111). Results never got that fix.
    • Software-renderer A/B on the real results screen: live frost costs 15.5 ms of 60 ms and the whole panel costs 22.7 ms. The nebula SubViewport behind it costs 8.0 ms, glow 4.5 ms, the dim rect 4.3 ms, hero lights 2.1 ms. Replacing the live frost with the snapshot (GlassPanel.snapshot_backdrop, applied at runtime in the probe) saves 16.1 ms, the same as removing the frost (16.2 ms), while keeping the frosted look (§2.7).
  • Change:
    • In ResultsPanel.show_results() build a snapshot backdrop the way LevelUpPanel does (ui/level_up_panel.gd:199-290: show_for → _snapshot → _drop_snapshot; backdrop_veil at :245): hide screen FX, wait one drawn frame, then GlassPanel.snapshot_backdrop(), set _glass.backdrop_texture and backdrop_veil, and free it on hide.
    • Optional (Chris’s call): while results are up, stop what is decoration only. Call nebula_bg.set_enabled(false), which stops its UPDATE_ALWAYS SubViewport (render/nebula_bg.gd:96-102); hide hero_lights; skip the per-frame enemy_renderer.sync() after the first frame.
  • Estimated gain: from about 43 ms to about 20-28 ms on the ATV, which is 23 fps to roughly 35-50 fps. The frost alone is the biggest single part. [Likely]: software A/B proportions applied to device. Mip-chain generation over an RGBA16F 4K target is plausibly worse on the A12 than in lavapipe.
  • Effort: S (the pattern and tests exist; tests/test_level_up_panel*-style coverage).
  • Risk:
    • Look: the frost stops “drifting”. The sim is frozen on the results screen anyway; only the nebula and FX decay move.
    • The snapshot frame must not contain the results card (the same care level-up already takes).
    • Determinism: none.
  • Verify:
    • On device, sit on results for 60 s before and after.
    • Telemetry: frozen samples with draws ~130 should go from 23 fps to at least 40. Hitch rows with state='gameover' should mostly disappear.
    • Locally: frame_probe.tscn -- --ab (the frost_snapshot row).

#2 Find out the Apple TV render resolution, and if it is 4K, render the canvas at 1080p on TV [Likely the biggest single lever]

Section titled “#2 Find out the Apple TV render resolution, and if it is 4K, render the canvas at 1080p on TV [Likely the biggest single lever]”
  • Evidence:
    • ATV frame is about 22 ms flat from 0 to 200 enemies, and 24 ms with none (§2.2).
    • The same A12 in an iPhone XS holds 60 fps at 2.7 MP (§2.3).
    • Stock Godot sets contentScaleFactor = UIScreen.scale (godot_view_apple_embedded.mm:127). On a 4K Apple TV that is 2.0, so 3840x2160.
    • platform/tvos/project.godot:11-14 renders canvas_items at native size, with hdr_2d=true (RGBA16F).
    • Every full-screen pass (clear, nebula upscale, static vignette fx/screen_feedback.gd:85-91, glow chain, tonemap) scales with pixel count.
  • Step 0 (XS effort, do first): add DisplayServer.window_get_size(), DisplayServer.screen_get_scale() and get_viewport().get_visible_rect().size to the launch event (EventQueue.push). That tells us the resolution from the next session onward.
  • Change, if 4K: render at 1920x1080 and let tvOS scale up to the panel. Two options:
    • (a) In the godot-tvos fork, set contentScaleFactor = 1.0 for tvOS (one line in the Apple Embedded view). The window becomes 1920x1080 and canvas_items keeps working unchanged at scale 1.5. The tvOS compositor upscales in hardware.
    • (b) Project-side: Window.content_scale_mode = VIEWPORT on TV, sized to 1920x1080. This needs a layout check, because content-scale semantics change.
  • Estimated gain: the fixed full-screen cost drops about 4x. The 22 ms baseline plausibly becomes 10-14 ms, which holds 50 fps with bloom ON and leaves headroom for bosses. [Guessing] for the number; [Likely] for the direction.
  • Effort: S (fork one-liner + template repack per bh-appstore-release §0.55) or M (project-side).
  • Risk:
    • Look: 1080p upscaled on a 4K TV is slightly softer text and hairlines (a 2x integer scale). Neon glow and bloom look the same or better, because bloom is resolution-relative.
    • UI layout: none for option (a).
    • Determinism: none.
  • Verify:
    • Launch event resolution first.
    • Then A/B samples.fps for the same enemy buckets and states, using analyze.py atv_builds bystate.
    • A perf_bench run (L0 vs L1) at the new resolution.

#3 Make bloom affordable instead of all-or-nothing, and switch the adaptive ladder back on [Certain that L1 hits 50 fps; the tuning is Likely]

Section titled “#3 Make bloom affordable instead of all-or-nothing, and switch the adaptive ladder back on [Certain that L1 hits 50 fps; the tuning is Likely]”
  • Evidence:
    • 9001 perf bench (§2.4): L0 35-38 fps, L1 = 50 fps cap at every load. L1 drops bloom, the HDR 2D target and point lights together (render/quality_manager.gd:130-145).
    • ADAPTIVE_QUALITY := false (main.gd:152), so no player session has ever used the ladder.
  • Change:
    1. Do #2 first. At 1080p bloom may simply fit.
    2. Split L1 so the costs can be measured one at a time. Make HDR+bloom, point lights and the nebula separate tiers, and run perf_bench on BUILD 21x to rank them.
    3. Cheaper bloom options, in order of look risk:
      • Drop the highest active glow level (each level runs a blur pass over a smaller mip).
      • Lower glow_hdr_scale / glow_intensity compensation.
      • Render the glow input at a lower mip.
    4. Re-enable ADAPTIVE_QUALITY for release (already the documented plan, docs/architecture/rendering-and-performance.md § ADAPTIVE_QUALITY). Give NebulaBg its predicate: it is already wired via nebula_on (quality_manager.gd:137), but docs/architecture/rendering-and-performance.md still says it is missing.
  • Estimated gain: 7+ ms (measured, capped) whenever the ladder engages.
  • Effort: S-M.
  • Risk:
    • Look: bloom is the neon signature. Shedding it should be the LAST resort after full-screen decoration (nebula, vignette). The ladder today sheds bloom FIRST, because it was the most expensive single item at L0. At 1080p that ordering should be re-measured.
    • Determinism: none.
  • Verify: DevFlags.perf_bench on device (tiers x enemy buckets, analyze.py quality).

#4 Boss fights: cheaper boss visuals [Likely]

Section titled “#4 Boss fights: cheaper boss visuals [Likely]”
  • Evidence:
    • Boss-labelled samples run at 16 fps (p10 13), proc 58 ms, draws 217, prims 15.8k (§2.1).
    • The Warden fight in 9594caba ran at 12-18 fps with 8-42 enemies (§2.6).
    • render/boss_renderer.gd:52-148 redraws every frame. It uses antialiased draw_arc with 48-84 segments and widths up to 15 px, plus spike, slash and tongue loops of AA draw_line, plus 3 draws per missile.
    • 3 boss PointLight2Ds at texture scale 9 (render/hero_lights.gd:20-22) light a roughly 1,700 px disc at 4K.
  • Change:
    • antialiased=false: HDR bloom hides the edge, and AA lines/arcs cost 3-4x the geometry and break batching.
    • Collect the spikes/slashes into one draw_multiline_colors.
    • Give missiles a MultiMesh (like the web layer).
    • Cap boss light radius, or have the boss use an additive sprite glow instead of a PointLight2D.
  • Estimated gain: unknown on device. Probably several ms in boss states. [Guessing]
  • Effort: M.
  • Risk:
    • Look: low-medium (slightly harder edges on telegraph rings). Needs the bh-vfx-iteration side-by-side.
    • Determinism: none.
  • Verify:
    • Add a state column (or d.state) to the sample event so boss windows can be selected directly.
    • tools/perf_probe draw deltas with a boss spawned via the dev console.

#5 Cap physics catch-up on slow frames [Certain mechanism; Likely gain]

Section titled “#5 Cap physics catch-up on slow frames [Certain mechanism; Likely gain]”
  • Evidence:
    • physics/common/max_physics_steps_per_frame is unset in both project files, so the default is 8 (main/main.cpp:2248).
    • At 12-18 fps the sim runs 3-5 ticks per frame. sim_ms max reaches 5-16 ms in 9594caba.
    • In the software probe, physics took 7-24 ms per rendered frame at around 15 fps, against 2-3.5 ms for all _process scripts.
    • Sim ticks run on the same main thread that must record the frame.
  • Change: set physics/common/max_physics_steps_per_frame=3 (or 4) in platform/tvos/project.godot. Under overload the game slows down briefly instead of spiralling. Godot shortens process_step accordingly (main/main.cpp:4951-4954).
  • Estimated gain: removes the multiplier on the worst frames (below 20 fps). Nothing changes at 30+ fps.
  • Effort: XS.
  • Risk:
    • Gameplay: in overload, wall-clock time and game time diverge (slow motion).
    • Determinism: none. Ticks stay a fixed Sim_Const.DT, and replays are tick-indexed.
    • Remember platform/tvos/project.godot comments use ; (CLAUDE.md).
  • Verify: compare hitch frame_ms p90 and sim_ms in late-run windows (analyze.py knees, run-minute table).

#6 Sim cost per tick: it is not negligible any more [Certain for the measurement; Likely for device share]

Section titled “#6 Sim cost per tick: it is not negligible any more [Certain for the measurement; Likely for device share]”
  • Evidence:
    • ATV sim_ms p50 is 2.7-4.4 ms per tick in play (BUILD 209: 4.4). At 60 ticks/s that is 16-26% of every wall-clock second on the main thread. The telemetry skill’s “sim is negligible” note predates today’s content.
    • Headless phase profile (this container, attract-style run): sim tick 0.26-1.9 ms mean on this x86 vCPU, dominated by enemy movement (about 45%), enemy attacks and status/zones/webs; it scales with live enemies (§2.7). No O(n²) phase showed up in a 6-minute run.
    • In attract mode the SkilledAutoPlayer planner runs inside _physics_process and measured 0.4-0.6 ms per tick at 120-200 enemies. It inflates sim_ms on auto=1 sessions only.
  • Change (hot spots confirmed in code; each fix below keeps iteration order, so no re-pin is needed):
    • hash.rebuild(enemies) runs up to about 7 times per tick: sim/sim.gd:846, sim/elemental_system.gd:89, and weapons (nova, orbit, thunder_wake, scar_tissue, wildfire). Add a dirty flag: rebuild only if positions or count changed since the last rebuild.
    • resolve_collisions queries the hash per projectile, allocating a PackedInt32Array each time (sim/elemental_system.gd:93-103). Add a whole-swarm AABB early-out per projectile, and use a reusable query buffer.
    • Webs are linear scans over Dictionaries, O(webs) per spider (sim/elemental_system.gd:300-318,371-375). Use a grid bucket with the same predicate.
  • Estimated gain: 20-40% of tick time. [Guessing]: measure with the profiler before and after.
  • Effort: M.
  • Risk: Determinism. Any change to candidate order changes which enemy a projectile hits first (break at the first hit), and so the checksum. Keep hash iteration order identical, do not replace the hash with brute force, and run all three determinism tests plus a soak. If a re-pin is unavoidable, use skill bh-determinism-repin.
  • Verify: sim_profile.gd µs/tick and phase split plus a checksum match (TOTAL ... checksum=), tools/sim_bench/sim_bench.gd, and the determinism tests.

#6b Attract-mode autopilot: throttle the planner [Certain headless; affects attract/demo sessions only]

Section titled “#6b Attract-mode autopilot: throttle the planner [Certain headless; affects attract/demo sessions only]”
  • Evidence: SkilledAutoPlayer costs 0.6-2.6 ms per tick headless, more than the sim itself once enemy bullets pile up (§2.7). main.gd:1303 uses it for every attract run. Its own header says it was built for soak / balance runs (ai/skilled_auto_player.gd:4).
  • Change: keep the per-tick plan cache, but re-plan only every 3-4 ticks, or when a lane’s soonest_impact falls under a threshold. Or cap the lanes considered to the N nearest bullets.
  • Estimated gain: 1-2 ms per tick of main-thread time in attract mode. It also makes auto=1 telemetry comparable with human sessions again. [Likely]
  • Effort: S.
  • Risk: the attract demo dodges slightly worse. Soak/economy-sim results depend on this AI, so gate the throttle to main.gd’s attract use (a flag on the instance). Leave the soak / economy-careers.sh behaviour byte-identical. Determinism: the sim is unaffected, only the input stream changes (attract runs are not baselines).

#7 Stop reallocating MultiMesh buffers every frame [Certain mechanism; Guessing gain]

Section titled “#7 Stop reallocating MultiMesh buffers every frame [Certain mechanism; Guessing gain]”
  • Evidence:
    • SwarmRenderer.sync sets instance_count = n whenever the pool size changes (render/swarm_renderer.gd:86-91). The same happens in ArchetypeRenderer.sync per shape bucket (render/archetype_renderer.gd:783-788) and for the aim lance (main.gd:2320-2323). Projectile counts change almost every frame.
    • In Godot 4.7.1, _multimesh_allocate_data frees and recreates the RD buffer and uniform set on every count change (servers/rendering/renderer_rd/storage_rd/mesh_storage.cpp:1547-1592).
    • SwarmRenderer.sync then makes 6 RenderingServer calls per instance (transform + colour × 3 layers).
    • Correction to a sub-agent hypothesis: stock 4.7.1 does not read the buffer back after a realloc (buffer_set=false → memset, mesh_storage.cpp:1581,1735-1743), so this is CPU churn, not a GPU sync stall. [Certain] for stock; the fork is unchecked.
  • Change:
    • Grow-only capacity with visible_instance_count (already the pattern in ZoneRenderer.web_capacity_for, render/zone_renderer.gd:96-119).
    • Write one PackedFloat32Array per layer and assign multimesh.buffer once per frame.
  • Estimated gain: CPU only. It matters only where the frame is CPU-bound (late run, many projectiles). [Guessing] 0.3-1 ms/frame.
  • Effort: S-M.
  • Risk: Look: none. Determinism: none.
  • Verify: frame_probe proc_ms in play before and after at a fixed seed; the draw count stays the same.

#8 Cheaper full-screen decoration while playing [Likely]

Section titled “#8 Cheaper full-screen decoration while playing [Likely]”
  • Static vignette. A full-screen TextureRect is visible at L0-2 (fx/screen_feedback.gd:85-91,137): one full-screen blended pass, most of it transparent. Draw it as a NinePatchRect with draw_center = false (border band only), or fold it into the tonemap/adjustment pass. No look change.
  • Nebula backdrop. Half-res UPDATE_ALWAYS SubViewport plus a full-screen upscale (render/nebula_bg.gd:18-48). The software A/B puts it at 8 ms of 60 on the results screen. Drop RESOLUTION_SCALE 0.5 → 0.35 (soft content; the same lesson as ThreatRadar’s 0.35), and/or render it every other frame. Low look risk.
  • Arena background. queue_redraw() every frame for star twinkle (render/arena_background.gd:113-118). Twinkle in a shader from TIME instead (the starfield already has render/star_field.gdshader).
  • Estimated gain: 1-3 ms at 4K. [Guessing]
  • Effort: S each. Determinism: none.
  • Verify: software A/B (frame_probe) plus a device perf_bench.

#9 Batch the FX pools [Likely for draw calls; Guessing for fps]

Section titled “#9 Batch the FX pools [Likely for draw calls; Guessing for fps]”
  • What:
    • FxManager gives each of 256 spark Sprite2Ds, each ring/beam/bolt/slash/ember node, its own CanvasItemMaterial (fx/fx_manager.gd:427-432 in the spark pool loop; the ring/beam/bolt/slash/ember inner classes at :54,155,199,245,309).
    • DeathDissolve duplicates a ShaderMaterial per sprite and sets 2 shader parameters per live sprite per frame (fx/death_dissolve.gd:31,68-71).
    • Different material instances cannot batch, so every live FX item is its own draw call [Likely] (the Godot canvas renderer starts a new batch on a material change).
    • burst rings (a pool of 24, each 2 AA arcs of 56-64 segments) fire on every aim-shot hit and top the hitch tags.
  • Change:
    • One shared static additive material per class.
    • Carry progress and tint in modulate/self_modulate (the _PlasmaNovaNode pattern, docs/architecture/rendering-and-performance.md § Plasma Nova).
    • Non-AA ring arcs.
  • Estimated gain: tens of draw calls in heavy fights; the fps effect is unmeasured.
  • Effort: S-M. Look: none if blend modes match. Determinism: none.

#10 Small CPU cleanups (cheap, low value individually)

Section titled “#10 Small CPU cleanups (cheap, low value individually)”
  • Hidden save-debug label. main.gd:1665 → _update_save_debug_label() (main.gd:2853-2870) runs MetaStore.debug_info() every frame: 4 file_exists, 2 FileAccess.open, about 12 string formats. The label’s parent has been hidden since 2026-07-02 (main.gd:327). Measured at 29 µs per call here (analysis/godot/debug_label_bench.gd); probably 50-150 µs on the A12. Fix: return early unless is_visible_in_tree(), or delete it. [Certain]
  • Damage numbers. They scan all 72 labels per dmgnum event, up to 28 per tick, from _physics_process (fx/damage_numbers.gd:43-64,94-112, fed at main.gd:1484), and reshape text on merge. Keep positions in a packed array and set text at most once per frame.
  • Telemetry hitch notes. telemetry.note_events builds a tag string and a Dictionary per render event per frame (net/telemetry.gd:100-107, net/hitch_tracker.gd). Note each distinct kind once per frame and skip dmgnum.
  • Per-frame UI rebuilds. weapon_panel / drone_dock / hud rebuild Dictionaries and strings or restyle StyleBoxes every frame. Cache them on change.
  • The Apple TV drawable size. It is inferred (4K is [Likely]), not measured. The godot-tvos fork source (~/Claude/godot-tvos) is not in this container, so I checked stock 4.7.1 only. Telemetry records no resolution. Add it to the launch event (#2, step 0).
  • Any on-device GPU timing. gpu_ms is NULL on Metal. Every GPU attribution here is by elimination (proc ~= frame, low render_cpu_ms, low script time in the probe) or from the software renderer. Xcode’s Metal System Trace / GPU frame capture on the dev app would settle #1-#4 in one session.
  • Software renderer ≠ A12. Lavapipe is an immediate-mode CPU rasteriser; the A12 is a tile-based GPU. Relative costs (frost ≈ 16 of 60 ms, nebula ≈ 8, glow ≈ 4.5) are indicative only. Screen copy + mip build is likely relatively worse on a TBDR GPU (it forces a tile store/resolve), and small overdraw relatively cheaper.
  • BUILD 209’s play-fps drop (35 vs about 42-46 on earlier builds) is explained by session mix: one long boss / late-run session among only 4 sessions. It is not proven to be code-neutral. A like-for-like perf_bench run on 209 vs 205 would confirm it.
  • Whether the tvOS fork shares upstream’s MultiMesh behaviour (no GPU readback after realloc, #7). Not checked.
  • Boss-state cost split (renderer vs lights vs weapon FX vs sim). Samples carry no state. I labelled windows via nearby hitch rows (n=21 boss windows), which is a biased subset.
  • Human vs autopilot. auto was 1 for the README’s “human” run 9594caba in both samples and runs. Either the run was autopiloted for some or all of its length, or the flag is wrong. sim_ms on auto=1 sessions includes the SkilledAutoPlayer planner (measured 0.25-2.6 ms per tick headless, §2.7), so their sim_ms overstates what a human session costs.
  • iOS-specific analysis. iPhone and iPad hold their refresh cap in play, and the game-over dip is milder there (iPad 94-96 vs 120 fps). I did not dig further. The same fixes (#1, #7, #9) apply.
  • Web. One session (BUILD 206, 60 fps capped). Nothing to analyse.
  • Nothing was measured on device and no fix was shipped. Estimated gains in §3 are ranges, tagged.

Everything lives on branch perf-review/2026-09-29 (created from perf-data/2026-09-29), under perf-data/2026-09-29/analysis/. No game code was changed. There is no PROTOTYPE commit: the only fix I measured, the results-screen snapshot frost, was toggled at runtime inside the probe.

Telemetry analysis (stdlib Python, reads the .json.gz dump):

python3 perf-data/2026-09-29/analysis/analyze.py # every section
python3 perf-data/2026-09-29/analysis/analyze.py bystate devices quality gameover knees consistency
section produces
inventory samples / sessions / play vs frozen per build × device
states ATV play vs frozen; frozen draw-call histogram
bystate ATV samples labelled by nearest hitch state (§2.1)
atv_builds per-build play stats and build × enemy-bucket fps (regression check)
knees fps / frame / draws vs enemies, draws, webs, zones, run minute (§2.2)
devices same-code device comparison (§2.3)
quality BUILD 9001 perf-bench tier sweep (§2.4)
gameover game-over hitches per build; long frozen stretches (§2.5)
consistency hitch windows and worst-frame distribution per device (§2.8)
hitches, nodes, runs, corr, ios hitch tags / worst hitches, node growth, runs, frame decomposition, other devices

The state classification and caveats are in the module docstring. The sample session 9594caba0d0bd830 is the BUILD 209 16:40-16:52 run. The equivalent SQL for the live D1 (for re-running on fresh data):

-- per build x enemy bucket, play-ish windows (run_time advancing is not expressible per row; use enemies>0)
SELECT build, CASE WHEN enemies<20 THEN '0-20' WHEN enemies<50 THEN '20-50' WHEN enemies<100 THEN '50-100' ELSE '100+' END b,
COUNT(*) n, ROUND(AVG(fps),1) fps, ROUND(AVG(render_cpu_ms),2) rcpu, ROUND(AVG(draws)) draws
FROM samples WHERE dev='tvOS/AppleTV11,1' AND build>=202 GROUP BY build, b ORDER BY b, build;
-- game-over cost per build
SELECT build, COUNT(*) n, ROUND(AVG(frame_ms)) frame, ROUND(AVG(proc_ms)) proc, ROUND(AVG(draws)) draws
FROM hitches WHERE state='gameover' GROUP BY build;
-- quality-tier A/B (perf_bench builds only)
SELECT quality, COUNT(*) n, ROUND(AVG(fps),1) fps FROM samples WHERE build=9001 GROUP BY quality;

Headless sim profile (Godot 4.7.1, godot --headless --path . --import first):

godot --headless --path . -s res://perf-data/2026-09-29/analysis/godot/sim_profile.gd -- \
--ticks=21600 --window=1800 --player=skilled [--picks=weapons|first] [--seed=1234]

profiled_sim.gd is GENERATED from sim/sim.gd’s tick(): a copy with _ph("phase") timers and per-weapon timing. Regenerate it with the Python snippet in analysis/README.md whenever sim.gd changes. It is measurement-only, and its final checksum= must equal a plain Sim run with the same input.

Frame probe (windowed; software Vulkan works in a headless container):

apt-get install -y mesa-vulkan-drivers xvfb # lavapipe
xvfb-run -a -s "-screen 0 1280x720x24" godot --path . --rendering-method mobile --rendering-driver vulkan \
--resolution 1280x720 res://perf-data/2026-09-29/analysis/godot/frame_probe.tscn -- \
--size=1280x720 --play=240 --window=20 --over=40 --breakdown=over --ab
git diff --quiet project.godot || git checkout -- project.godot

On macOS, run the same scene windowed with the real Metal renderer, and the A/B rows become real Apple GPU numbers:

godot --path . --rendering-method mobile res://perf-data/2026-09-29/analysis/godot/frame_probe.tscn -- --ab

Debug-label microbench: godot --headless --path . -s res://perf-data/2026-09-29/analysis/godot/debug_label_bench.gd.

Environment check: scripts/check-test-count.sh gave 335/335 scripts and 3,337 passing tests on this branch’s base.

Tooling debt found on the way: tools/sim_bench/sim_bench.gd:27 calls s.apply_upgrade(...), which no longer exists on Sim (it is sim.upgrade_system.apply_upgrade(sim, pilot, id)). The bench survives only because its scripted circling input never levels up, so it also never profiles an upgraded, late-run build. CLAUDE.md’s “the sole mutations are sim.tick(input) and sim.apply_upgrade(id)” is stale for the same reason. Candidate for NEXT_TASKS.md.