On-device perf scenario — design
On-device perf scenario — design
Section titled “On-device perf scenario — design”Date: 2026-10-01. Status: approved in brainstorm (Chris), pending spec review.
Tracked in docs/RELEASE-1.0.1.md §1 (“On-device perf scenario in the Remote Playtest Console”).
BUILD 235 telemetry shows a ~50-64 ms GPU stall per Supernova cast, worsened by webs and zones,
but the evidence is pooled across uncontrolled runs. Remote-config tunables only apply at run
start, the hitch log ignores stalls under 60 ms, and tools/perf_probe (which has the right
A/B method) only runs on the Mac. We need the same interleaved, render-only A/B on the Apple TV
(and iPhone/iPad), started from the Remote Playtest Console, with results we can trust.
Decisions (from the brainstorm)
Section titled “Decisions (from the brainstorm)”- The schedule runs on the device, not driven step-by-step from the panel (relay latency and poll gaps would blur segment boundaries).
- Canned presets for the loadout, not the current run, so results compare across builds/devices.
- Results: live in the panel (device computes the table, publishes it via
/status) and in D1 (oneEventQueueevent per segment). No new route between the control and telemetry Workers. - v1 B-arms: Supernova shockwave, zone layer, web layer, and any v2 weapon look.
- Default 8 segments × 10 s, 5 s warmup, XP held at 0.
Architecture
Section titled “Architecture”Shared rig — render/perf_rig.gd (class_name PerfRig, static funcs taking main)
Section titled “Shared rig — render/perf_rig.gd (class_name PerfRig, static funcs taking main)”Extracted from tools/perf_probe/perf_probe.gd so the Mac probe and the device scenario run the
SAME code:
hold_targets(main, n)— nTYPE_CUSTOMdev targets (Sim.dev_spawn_custom, hp 1e9, speed 0, contact 0) re-pinned on a ring (TARGET_RING70 px) around P1 every frame; respawns any lost.hold_webs(main, n, fade)/hold_zones(main, n)— keepsim.webs/sim.zonesfilled with n on-screen entries re-pinned around P1 (perf_probe’s current grid/fade/element logic, verbatim).hold_xp(main)— every pilot’s XP to 0.arm_ids()— the arm roster:nova_shockwave,zones,webs, plus every v2 look id (blade,scythe,nova,blaster,bolts,orbit).hide_arm(main, arm)— called fromRenderingServer.frame_pre_draw: zeroesvisible_instance_counton the arm’sMultiMeshInstance2D(zone_renderer._zone_layer,zone_renderer._web_layer,fx_layer.v2_look(w),bolt_renderer,orbit_look).nova_shockwave= the v2novalook whenfx_layer.v2_active(), else the v1 FxManager nova (needs a small FxManager accessor/hide seam if one doesn’t exist — the plan verifies). ZoneRenderer gains public accessors for its two layers rather than the rig reaching into_fields.live_count(main, arm)— visible instances of the arm’s layer (validity check below).
perf_probe.gd is migrated onto PerfRig; its flags (--targets, --webs, --zones,
--hide-v2, --ab) keep identical behaviour (smoke-run to confirm).
Presets — PerfScenario.PRESETS const
Section titled “Presets — PerfScenario.PRESETS const”{name: {weapons: [wid…], evolve: bool}}, starting with:
baseline— starting weapon only.supernova— nova, evolved.batch1_evolved— blaster, orbit, blade, scythe, nova, all evolved.
Applied via dev_reset_upgrades → dev_force_grant_weapon → dev_add_crystals (the real
WeaponThresholds path, so evolutions apply exactly as a picked crystal card would). A unit test
asserts every preset wid exists and every evolve: true preset actually ends evolved.
Runner — render/perf_scenario.gd (class_name PerfScenario extends Node)
Section titled “Runner — render/perf_scenario.gd (class_name PerfScenario extends Node)”Render-side, child of main, created on the first perf_scenario command. The pure parts live in
PerfScenarioCore (RefCounted, no Node APIs) so they’re unit-testable:
- Scheduler: states
SETUP → WARMUP → SEGMENT(i, side) → SETTLE → … → DONE | CANCELLED. Side order is ABBA repeated (A B B A A B B A for 8). Settle between segments = 0.8 s (longest look life, evolved Supernova 0.70 s, plus margin), budgeted insim.run_time, not frames. - Stats: per segment, from per-frame samples: p50/p95/max frame ms (main-thread
_processdelta),n_over_33, meanrender_cpu_ms(existingTelemetryreader,net/telemetry.gd),gpu_proxy_ms = render_ms − render_cpu_ms, mean enemies/webs/zones, arm casts fired (fromsim.render_fx_events), mean arm live instances, andHitchTracker.recent_tags()captured at the segment’s worst frame. - Validity: a segment is invalid if sim advanced < 90% of wall time, main was frozen on a
level-up/dimension panel at any point, or (B side, and A side for event looks) the arm’s look was
never live. Invalid segments are re-run in place; give up after 2 × segments attempts → DONE with
incomplete: true. - Summary: per-side medians over valid segments and B − A deltas.
Command + status — main.apply_dev_command
Section titled “Command + status — main.apply_dev_command”{cmd:"perf_scenario", preset, arm, segments=8, seg_s=10, warmup_s=5, targets=12, webs=0, zones=0}— validated (preset/arm in roster, numbers clamped: segments 2-20 even, seg_s 2-60, counts 0-400). Ignored unlessBuildConfig.dev_tools(). A second start while running is refused (status note).{cmd:"perf_scenario_cancel"}.- SETUP:
dev_reset_upgrades,dev_clear_enemies, invuln on, spawns paused, preset applied. Prior values of invuln/pause are saved for teardown. build_caps()addsperf_presets(name → weapons) andperf_arms.build_status()addsperf_scenario: {state, scenario_id, preset, arm, seg, of, side, valid_count, rows:[…], summary:{A:{…}, B:{…}, delta:{…}}, incomplete, note}(null when never run).
Teardown
Section titled “Teardown”On DONE, cancel, run end, or return to menu: disconnect the frame_pre_draw hide, restore saved
invuln/pause-spawn values, stop holding XP/targets/webs/zones and drop the held entries
(dev_clear_enemies). Layer visibility needs no restore (hiding is per-frame).
Telemetry
Section titled “Telemetry”Per segment: EventQueue.push("perf_seg", {scenario_id, preset, arm, seg, side, valid, frames, p50, p95, max, n_over_33, render_cpu_ms, gpu_proxy_ms, enemies, webs, zones, fired, live, worst_recent}). Verified: the telemetry Worker accepts any type matching /^[a-z_]{1,24}$/ and
stores unknown types as JSON in events.data — no Worker, D1 or retention change. Build,
session, channel and config_id ride on the standard envelope. No privacy-page/ASC-label change: same
kind of data (device performance) already disclosed, and dev builds only.
Query later: SELECT data FROM events WHERE type='perf_seg' AND json_extract(data,'$.scenario_id')=?.
Panel — control/src/panel.html.js
Section titled “Panel — control/src/panel.html.js”New Perf tab, caps-driven: preset, arm, segments, seg length, warmup, targets/webs/zones,
Run/Cancel; live progress strip; per-segment table + A-median / B-median / delta row; the
scenario_id shown for D1 lookup. textContent only (existing XSS guard). Pane reveal uses
display='block' (the known stylesheet gotcha).
Testing
Section titled “Testing”- GUT:
PerfScenarioCorescheduler (ABBA order, settle, re-run of invalid segments, give-up → incomplete), percentile/median maths, validity rules; preset table (wids exist, evolve presets end evolved via the real crystal path); command validation/clamping and dev-tools gate. - GUT integration (headless): a 2 × 1 s scenario through
main.apply_dev_commandemits 2 validperf_segevents (or explicitly-invalid ones where headless can’t render — the test asserts on the event shape and teardown, not frame numbers) and restores invuln/pause state. control/test/panel.test.mjs: Perf tab renders from caps; status table renders.- perf_probe smoke run after the rig extraction (
--targets=12 --hide-v2=nova --ab=1). - Determinism baselines unaffected (nothing runs unless the command is sent);
scripts/gate.shgreen. - On-device acceptance: one
supernova×nova_shockwaverun on the ATV dev app; 8 valid segments, a readable panel table, matchingperf_segrows in D1.
Out of scope (v1)
Section titled “Out of scope (v1)”Cross-build history in the panel (ad-hoc SQL covers it); multi-arm (ABCD) runs — run once per arm;
sim-side (non-render) arms such as REMNANT_FRAC, which would break the render-only guarantee.
docs/architecture/dev-tools.md § Remote Playtest Console gains a “Perf scenario” subsection;
docs/RELEASE-1.0.1.md item ticked on merge.
Corrections (found while planning, 2026-10-01 — these override the text above)
Section titled “Corrections (found while planning, 2026-10-01 — these override the text above)”- Dev commands key on
kind, notcmd:{kind:"perf_scenario", …}/{kind:"perf_scenario_cancel"}. apply_dev_commandhad no dev-tools gate (the console’s arm path is gated upstream); theperf_scenarioarm adds its ownBuildConfig.dev_tools()check.main._processdrainssim.render_fx_eventsbefore children run, so main forwardsrender_eventstoperf_scenario.note_events()next totelemetry.note_events().- Telemetry’s
render_msisPerformance.TIME_PROCESS, not a render measure, sogpu_proxy_msis dropped: segments record rawframe_msstats (≈ GPU time on a GPU-bound device),process_ms(TIME_PROCESS) andrender_cpu_ms; derive splits offline. EventQueue.pushis a no-op headless/in-editor, so the node takes an injectablepush_eventCallable (defaults tomain.event_queue.push); tests spy on it.- Presets are crystal counts per element (rules are independent, so the evolve rung fires on its
own count):
supernova= nova + fire 12 + light 18;batch1_evolved= blaster/orbit/blade/scythe/nova- fire 12, void 18, light 18, blood 18. The wallet is shared, so other owned weapons on those elements also cross their rungs — intended.
- v1 Supernova (v2 off, quality L3+) is hidden by setting pooled
PlasmaNovaNode.visible = false(sticks until the node’s next reset); this also hides Scar Tissue novas. Fallback_spawn_ring(only whenVisualLook.plasma_novais off) is not covered.