General performance review (2026-09-29)
General performance review (2026-09-29)
Section titled “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.
Executive summary
Section titled “Executive summary”Bottom line:
- The Apple TV is GPU/fill-bound in every state.
render_cpu_msis 0.7-2.2 ms, and our_processscripts 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:
- 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.
- The results card is a live-frosted GlassPanel (
- 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 usescanvas_itemsstretch 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
launchevent. 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.
- Stock Godot sets
- 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_QUALITYis stillfalse, so no real session has used the ladder. The ladder also cannot help the game-over screen (it stops ticking atsim.game_over).- Make bloom cheaper, and at the new resolution, before letting the ladder shed it. Bloom is the neon look.
- 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
burstrings 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.
- Cause: boss visuals (antialiased arcs and lines redrawn every frame, big boss PointLight2Ds), per-hit FX (the aim shot’s
- 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.
1. Data and method
Section titled “1. Data and method”1.1 What is in the dump
Section titled “1.1 What is in the dump”| 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) andproc_ms(hitches) arePerformance.TIME_PROCESS. In Godot 4.7 that is the per-second maximum of the time spent inMainLoop::processplusRenderingServer::sync()anddraw(). When the GPU is the bottleneck, the CPU blocks insidedraw()waiting for a drawable, soproc ~= framemeans “GPU-bound or script-bound”, not “script-bound”. [Certain] (engine main loop,docs/architecture/dev-tools.md§ Telemetry v2).sim_msisTIME_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 carrytick_ms/tick_max_ms/ticks, the mean and worst measuredsim.tickover the whole window; use those for sim cost. It includesmain._physics_process(input, theSkilledAutoPlayerplanner 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_msis NULL on Metal (the tvOS fork has no timestamp queries). There is no direct GPU measurement on any Apple device. GPU cost is inferred fromframe - (CPU parts). [Certain]- Samples carry no
state. I label themplaywhenrun_timeadvanced since the previous sample of the session andfrozenotherwise (game-over, pause, level-up, codex card, warp). A second labelling joins each sample to the nearest hitch row (<= 6 s) and takes itsstate, 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.
qualityis 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 --importOK.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.tscnunder 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(+ generatedprofiled_sim.gd, a phase-timed copy ofSim.tick) andframe_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. Findings from the data
Section titled “2. Findings from the data”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_msis 0.7-2.2 ms whileproc ~= framein every state. The frozen states run no sim (sim_ms0.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_processmeasures 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:
- 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-14hasstretch/mode="canvas_items"with no resolution cap, andviewport/hdr_2d=truemakes 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. - Engine build. tvOS runs the custom fork (
GodotEngine/4.7.dev.custom_build (tvOS), 8,362 samples), while iOS runs stock4.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 (
QualityManagerL1: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 oncesim.game_overis set (main.gd:1750returns before thequality_manager.tickatmain.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
99c069absat 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.gdshipped in between), every long ATV game-over stretch runs at 23 fps: proc 43-44 ms,render_cpu1-3 ms, draws 104-232,sim_ms0.2. That covers sessions965d9abc,5f1d4575,336bc880and9594caba(the 16:50 run). Hitches ingameoverstate: 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_ALWAYSat 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
ColorRectsits 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_msmax 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
_processscripts 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 bymain.gd:1303for 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 theDangerLanesescape and warp search: 16 directions × escape rings ×is_clearover 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 inflatessim_msand main-thread load on everyauto=1session, including 3 of the 4 BUILD 209 sessions. [Certain] (headless). The device share is [Likely].
2.8 Frame-time consistency
Section titled “2.8 Frame-time consistency”| 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_frameis 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 in9594caba, 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]
2.9 Recurring hitch context
Section titled “2.9 Recurring hitch context”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]
3. Ranked improvement opportunities
Section titled “3. Ranked improvement opportunities”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:78setsfrosted = true.ui/theme/glass_frost.gdshader:5,13reads 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 wayLevelUpPaneldoes (ui/level_up_panel.gd:199-290:show_for→_snapshot→_drop_snapshot;backdrop_veilat :245): hide screen FX, wait one drawn frame, thenGlassPanel.snapshot_backdrop(), set_glass.backdrop_textureandbackdrop_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 itsUPDATE_ALWAYSSubViewport (render/nebula_bg.gd:96-102); hidehero_lights; skip the per-frameenemy_renderer.sync()after the first frame.
- In
- 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(thefrost_snapshotrow).
#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-14renderscanvas_itemsat native size, withhdr_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()andget_viewport().get_visible_rect().sizeto thelaunchevent (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-tvosfork, setcontentScaleFactor = 1.0for 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 = VIEWPORTon TV, sized to 1920x1080. This needs a layout check, because content-scale semantics change.
- (a) In the
- 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.fpsfor the same enemy buckets and states, usinganalyze.py atv_builds bystate. - A
perf_benchrun (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.
- 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 (
- Change:
- Do #2 first. At 1080p bloom may simply fit.
- 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_benchon BUILD 21x to rank them. - 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_intensitycompensation. - Render the glow input at a lower mip.
- Re-enable
ADAPTIVE_QUALITYfor release (already the documented plan,docs/architecture/rendering-and-performance.md§ ADAPTIVE_QUALITY). GiveNebulaBgits predicate: it is already wired vianebula_on(quality_manager.gd:137), butdocs/architecture/rendering-and-performance.mdstill 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_benchon 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
9594cabaran at 12-18 fps with 8-42 enemies (§2.6). render/boss_renderer.gd:52-148redraws every frame. It uses antialiaseddraw_arcwith 48-84 segments and widths up to 15 px, plus spike, slash and tongue loops of AAdraw_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-iterationside-by-side. - Determinism: none.
- Look: low-medium (slightly harder edges on telegraph rings). Needs the
- Verify:
- Add a
statecolumn (ord.state) to thesampleevent so boss windows can be selected directly. tools/perf_probedraw deltas with a boss spawned via the dev console.
- Add a
#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_frameis 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_msmax reaches 5-16 ms in9594caba. - In the software probe, physics took 7-24 ms per rendered frame at around 15 fps, against 2-3.5 ms for all
_processscripts. - 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) inplatform/tvos/project.godot. Under overload the game slows down briefly instead of spiralling. Godot shortensprocess_stepaccordingly (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.godotcomments use;(CLAUDE.md).
- Verify: compare hitch
frame_msp90 andsim_msin 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_msp50 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
SkilledAutoPlayerplanner runs inside_physics_processand measured 0.4-0.6 ms per tick at 120-200 enemies. It inflatessim_msonauto=1sessions only.
- ATV
- 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_collisionsqueries the hash per projectile, allocating aPackedInt32Arrayeach 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 (
breakat 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 skillbh-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:
SkilledAutoPlayercosts 0.6-2.6 ms per tick headless, more than the sim itself once enemy bullets pile up (§2.7).main.gd:1303uses 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_impactfalls 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=1telemetry 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.shbehaviour 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.syncsetsinstance_count = nwhenever the pool size changes (render/swarm_renderer.gd:86-91). The same happens inArchetypeRenderer.syncper 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_datafrees and recreates the RD buffer and uniform set on every count change (servers/rendering/renderer_rd/storage_rd/mesh_storage.cpp:1547-1592). SwarmRenderer.syncthen 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 inZoneRenderer.web_capacity_for,render/zone_renderer.gd:96-119). - Write one
PackedFloat32Arrayper layer and assignmultimesh.bufferonce per frame.
- Grow-only capacity with
- 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_probeproc_msin 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
TextureRectis visible at L0-2 (fx/screen_feedback.gd:85-91,137): one full-screen blended pass, most of it transparent. Draw it as aNinePatchRectwithdraw_center = false(border band only), or fold it into the tonemap/adjustment pass. No look change. - Nebula backdrop. Half-res
UPDATE_ALWAYSSubViewport 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. DropRESOLUTION_SCALE0.5 → 0.35 (soft content; the same lesson asThreatRadar’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 fromTIMEinstead (the starfield already hasrender/star_field.gdshader). - Estimated gain: 1-3 ms at 4K. [Guessing]
- Effort: S each. Determinism: none.
- Verify: software A/B (
frame_probe) plus a deviceperf_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:
FxManagergives each of 256 sparkSprite2Ds, each ring/beam/bolt/slash/ember node, its ownCanvasItemMaterial(fx/fx_manager.gd:427-432in the spark pool loop; the ring/beam/bolt/slash/ember inner classes at:54,155,199,245,309).DeathDissolveduplicates aShaderMaterialper 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).
burstrings (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_PlasmaNovaNodepattern,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) runsMetaStore.debug_info()every frame: 4file_exists, 2FileAccess.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 unlessis_visible_in_tree(), or delete it. [Certain] - Damage numbers. They scan all 72 labels per
dmgnumevent, up to 28 per tick, from_physics_process(fx/damage_numbers.gd:43-64,94-112, fed atmain.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_eventsbuilds 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 skipdmgnum. - Per-frame UI rebuilds.
weapon_panel/drone_dock/hudrebuild Dictionaries and strings or restyle StyleBoxes every frame. Cache them on change.
4. What I could not verify
Section titled “4. What I could not verify”- The Apple TV drawable size. It is inferred (4K is [Likely]), not measured. The
godot-tvosfork source (~/Claude/godot-tvos) is not in this container, so I checked stock 4.7.1 only. Telemetry records no resolution. Add it to thelaunchevent (#2, step 0). - Any on-device GPU timing.
gpu_msis NULL on Metal. Every GPU attribution here is by elimination (proc ~= frame, lowrender_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_benchrun 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.
autowas 1 for the README’s “human” run9594cabain bothsamplesandruns. Either the run was autopiloted for some or all of its length, or the flag is wrong.sim_msonauto=1sessions includes theSkilledAutoPlayerplanner (measured 0.25-2.6 ms per tick headless, §2.7), so theirsim_msoverstates 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.
5. Reproducing this review
Section titled “5. Reproducing this review”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 sectionpython3 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)) drawsFROM samples WHERE dev='tvOS/AppleTV11,1' AND build>=202 GROUP BY build, b ORDER BY b, build;-- game-over cost per buildSELECT build, COUNT(*) n, ROUND(AVG(frame_ms)) frame, ROUND(AVG(proc_ms)) proc, ROUND(AVG(draws)) drawsFROM 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 # lavapipexvfb-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 --abgit diff --quiet project.godot || git checkout -- project.godotOn 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 -- --abDebug-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.