On-device Perf Scenario Implementation Plan
On-device Perf Scenario Implementation Plan
Section titled “On-device Perf Scenario Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. In this repo also followbh-sdd-concurrent(other sessions sharemain) andbh-dev-chunk(gate ritual).
Goal: One Remote Playtest Console command runs a fixed, render-only ABBA perf A/B on the device and reports per-segment frame stats live (panel) and durably (D1 perf_seg events).
Architecture: A pure PerfScenarioCore (scheduler + stats + validity) is driven by a render-side PerfScenario node that main.apply_dev_command creates. The holds/hides it uses live in a shared static PerfRig, which tools/perf_probe is migrated onto so Mac and device measure the same way. Results go out via build_status().perf_scenario (panel) and event_queue.push("perf_seg", …) (telemetry, no Worker change).
Tech Stack: Godot 4.7 typed GDScript (warnings-as-errors), GUT, the control Worker panel (control/src/panel.html.js, node:test).
Spec: docs/superpowers/specs/2026-10-01-on-device-perf-scenario-design.md (read it; the “Corrections” section there overrides the earlier text).
Global Constraints
Section titled “Global Constraints”- Dev command dicts key on
"kind"(match String(cmd.get("kind", ""))), never"cmd". /simis untouched: no new sim code. Everything here is render/dev side. Determinism baselines must not move.- Dev-only: the
perf_scenarioarm returns immediately unlessBuildConfig.dev_tools()(true in the editor binary, so headless GUT passes). - Survival only: refuse unless
main.sim.ruleset == Sim.RULESET_SURVIVAL(crystal thresholds only evaluate there). - Waits are budgeted in sim time (
sim.run_timedeltas), never frame counts. - Render observers read events forwarded from
main._process(render_events), because main drainssim.render_fx_eventsbefore its children’s_processruns. - Constants:
SETTLE_S = 0.8,MIN_SIM_RATE = 0.9,OVER_MS = 33.0,TARGET_RING = 70.0,TARGET_HP = 1.0e9, max attempts =2 × segments. - Param clamps:
segments2-20 and even,seg_s2-60,warmup_s0-30,targets/webs/zones0-400. Defaults 8 / 10 / 5 / 12 / 0 / 0. - Commits:
git commit -F <file>with the session’s Co-Authored-By/Claude-Session trailer;git push origin mainafter every merge. - Single GUT file:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit -gselect=<file>.gd(fresh worktree:godot --headless --path . --importfirst). Full suite:scripts/check-test-count.sh(never pipe a raw run into context). Gate:bash scripts/gate.sh.
File Structure
Section titled “File Structure”| File | Responsibility |
|---|---|
render/perf_scenario_core.gd (new) |
Pure: ABBA order, phase state machine, per-segment stats, validity, summary |
render/perf_rig.gd (new) |
Static helpers on main/sim: hold targets/webs/zones/xp, arm roster, hide an arm, live count |
render/perf_scenario.gd (new) |
Node: presets, param normalisation, setup/teardown, per-frame sampling, event push, status() |
render/zone_renderer.gd |
+ web_layer() / zone_layer() accessors |
main.gd |
perf_scenario member, 2 command arms, caps/status keys, event forwarding |
tools/perf_probe/perf_probe.gd |
Migrated onto PerfRig (behaviour unchanged) |
control/src/panel.html.js |
Perf tab: form, progress, table |
tests/test_perf_scenario_core.gd, tests/test_perf_rig.gd, tests/test_perf_scenario.gd (new) |
GUT |
control/test/panel.test.mjs |
Perf tab tests |
docs/architecture/dev-tools.md, docs/RELEASE-1.0.1.md |
Docs |
Task 1: PerfScenarioCore (pure scheduler + stats)
Section titled “Task 1: PerfScenarioCore (pure scheduler + stats)”Files:
- Create:
render/perf_scenario_core.gd - Test:
tests/test_perf_scenario_core.gd
Interfaces:
-
Produces:
class_name PerfScenarioCore extends RefCountedwithenum State { IDLE, WARMUP, SETTLE, SEGMENT, DONE, CANCELLED },var state: State,var rows: Array[Dictionary],var incomplete: bool,var seg: int,var order: Array[String],static func abba(n: int) -> Array[String],static func percentile(sorted: Array[float], p: float) -> float,static func median(v: Array[float]) -> float,func start(segments: int, seg_seconds: float, warmup_seconds: float) -> void,func running() -> bool,func side() -> String,func hidden_now() -> bool,func state_name() -> String,func step(sim_dt: float, wall_dt: float) -> Dictionary(returns the finished row or{}),func sample(frame_ms: float, process_ms: float, render_cpu_ms: float, enemies: int, webs: int, zones: int, live: int) -> bool(true = new worst frame),func note_fired(n: int) -> void,func note_frozen() -> void,func set_worst_recent(tags: String) -> void,func cancel() -> void,func valid_count() -> int,func summary() -> Dictionary. Row keys:seg, side, valid, frames, p50, p95, max, n_over_33, process_ms, render_cpu_ms, enemies, webs, zones, fired, live, sim_rate, frozen, worst_recent. -
Step 1: Write the failing tests
extends GutTest# PerfScenarioCore: the pure ABBA scheduler + stats behind the on-device perf scenario.
const DT := 1.0 / 60.0
func _run_segment(c: PerfScenarioCore, frame_ms: float, live: int, frozen: bool = false) -> Dictionary: # Drive one full settle + segment at real-time rate. Returns the finished row. for _i in 100000: if c.state == PerfScenarioCore.State.SEGMENT: c.sample(frame_ms, 1.0, 0.5, 10, 0, 0, live) if frozen: c.note_frozen() var row := c.step(DT, DT) if not row.is_empty(): return row return {}
func test_abba_order() -> void: assert_eq(PerfScenarioCore.abba(8), ["A", "B", "B", "A", "A", "B", "B", "A"]) assert_eq(PerfScenarioCore.abba(2), ["A", "B"])
func test_percentile_and_median() -> void: var v: Array[float] = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0] assert_eq(PerfScenarioCore.percentile(v, 0.5), 5.0) assert_eq(PerfScenarioCore.percentile(v, 0.95), 10.0) assert_eq(PerfScenarioCore.median([3.0, 1.0, 2.0] as Array[float]), 2.0) assert_eq(PerfScenarioCore.median([4.0, 1.0, 2.0, 3.0] as Array[float]), 2.5)
func test_warmup_then_settle_then_segment() -> void: var c := PerfScenarioCore.new() c.start(2, 2.0, 1.0) assert_eq(c.state, PerfScenarioCore.State.WARMUP) for _i in 61: c.step(DT, DT) assert_eq(c.state, PerfScenarioCore.State.SETTLE) for _i in 49: c.step(DT, DT) assert_eq(c.state, PerfScenarioCore.State.SEGMENT) assert_eq(c.side(), "A") assert_false(c.hidden_now())
func test_full_run_rows_and_summary() -> void: var c := PerfScenarioCore.new() c.start(2, 2.0, 0.0) var a := _run_segment(c, 20.0, 5) assert_true(a.valid) assert_eq(a.side, "A") assert_eq(a.p50, 20.0) assert_eq(c.side(), "B") # the settle before a B segment is already hidden c.step(DT, DT) assert_true(c.hidden_now()) var b := _run_segment(c, 40.0, 5) assert_true(b.valid) assert_eq(b.n_over_33, b.frames) assert_eq(c.state, PerfScenarioCore.State.DONE) var s := c.summary() assert_eq(s.A.p50, 20.0) assert_eq(s.B.p50, 40.0) assert_eq(s.delta.p50, 20.0)
func test_invalid_segments_rerun_then_give_up() -> void: var c := PerfScenarioCore.new() c.start(2, 2.0, 0.0) var r := _run_segment(c, 20.0, 0) # look never live -> invalid assert_false(r.valid) assert_eq(c.side(), "A", "an invalid segment is re-run in place") for _i in 3: _run_segment(c, 20.0, 0) assert_eq(c.state, PerfScenarioCore.State.DONE) assert_true(c.incomplete) assert_eq(c.rows.size(), 4, "2 x segments attempts")
func test_frozen_or_slow_sim_is_invalid() -> void: var c := PerfScenarioCore.new() c.start(2, 2.0, 0.0) assert_false(_run_segment(c, 20.0, 5, true).valid) # sim advancing at half wall rate for _i in 100000: if c.state == PerfScenarioCore.State.SEGMENT: c.sample(20.0, 1.0, 0.5, 0, 0, 0, 5) var row := c.step(DT * 0.5, DT) if not row.is_empty(): assert_false(row.valid) assert_almost_eq(row.sim_rate, 0.5, 0.01) return fail_test("segment never finished")
func test_worst_recent_tracks_the_worst_frame() -> void: var c := PerfScenarioCore.new() c.start(2, 2.0, 0.0) while c.state != PerfScenarioCore.State.SEGMENT: c.step(DT, DT) assert_true(c.sample(20.0, 1.0, 0.5, 0, 0, 0, 1)) c.set_worst_recent("nova") assert_false(c.sample(10.0, 1.0, 0.5, 0, 0, 0, 1)) assert_true(c.sample(64.0, 1.0, 0.5, 0, 0, 0, 1)) c.set_worst_recent("nova,web") var row := {} while row.is_empty(): row = c.step(DT, DT) assert_eq(row.worst_recent, "nova,web") assert_eq(row.max, 64.0)
func test_cancel() -> void: var c := PerfScenarioCore.new() c.start(2, 2.0, 0.0) c.cancel() assert_false(c.running()) assert_eq(c.state_name(), "cancelled")- Step 2: Run, verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit -gselect=test_perf_scenario_core.gd
Expected: FAIL / parse error, PerfScenarioCore not found.
- Step 3: Implement
render/perf_scenario_core.gd
class_name PerfScenarioCoreextends RefCounted# Pure scheduler + stats for the on-device perf scenario (render/perf_scenario.gd drives it; spec# docs/superpowers/specs/2026-10-01-on-device-perf-scenario-design.md). No Node APIs: every input# arrives through step()/sample()/note_*() so it is unit-testable.## Phases: WARMUP once, then per segment SETTLE (0.8 s, lets the previous arm's looks expire; the# evolved Supernova lives 0.70 s) -> SEGMENT. Sides follow ABBA. A segment is invalid if the sim ran# below MIN_SIM_RATE of wall time, main froze on a panel, no frame was sampled, or the arm's look was# never live; invalid segments are re-run in place, giving up after 2 x segments attempts.
enum State { IDLE, WARMUP, SETTLE, SEGMENT, DONE, CANCELLED }
const SETTLE_S := 0.8const MIN_SIM_RATE := 0.9const OVER_MS := 33.0const STATE_NAMES := ["idle", "warmup", "settle", "segment", "done", "cancelled"]
var state: State = State.IDLEvar order: Array[String] = []var seg := 0 # index into order of the current (or next) segmentvar attempts := 0var max_attempts := 0var seg_s := 10.0var warmup_s := 5.0var rows: Array[Dictionary] = []var incomplete := false
var _t := 0.0 # sim seconds in the current phasevar _wall := 0.0 # wall seconds in the current phasevar _frames: Array[float] = []var _proc_sum := 0.0var _cpu_sum := 0.0var _enemies_sum := 0var _webs_sum := 0var _zones_sum := 0var _live_sum := 0var _live_seen := falsevar _fired := 0var _frozen := falsevar _worst := -1.0var _worst_recent := ""
static func abba(n: int) -> Array[String]: var out: Array[String] = [] for i in n: out.append("A" if i % 4 == 0 or i % 4 == 3 else "B") return out
# Nearest-rank percentile of an ascending-sorted array.static func percentile(sorted: Array[float], p: float) -> float: if sorted.is_empty(): return 0.0 var idx := clampi(ceili(p * sorted.size()) - 1, 0, sorted.size() - 1) return sorted[idx]
static func median(v: Array[float]) -> float: if v.is_empty(): return 0.0 var s := v.duplicate() s.sort() var n := s.size() return s[n / 2] if n % 2 == 1 else (s[n / 2 - 1] + s[n / 2]) * 0.5
func start(segments: int, seg_seconds: float, warmup_seconds: float) -> void: order = abba(segments) seg_s = seg_seconds warmup_s = warmup_seconds seg = 0 attempts = 0 max_attempts = segments * 2 rows.clear() incomplete = false _enter(State.WARMUP)
func running() -> bool: return state == State.WARMUP or state == State.SETTLE or state == State.SEGMENT
func side() -> String: return order[seg] if seg < order.size() else ""
# True while the B arm should be hidden: its settle and its segment.func hidden_now() -> bool: return (state == State.SETTLE or state == State.SEGMENT) and side() == "B"
func state_name() -> String: return STATE_NAMES[state]
func cancel() -> void: if running(): state = State.CANCELLED
func valid_count() -> int: var n := 0 for r in rows: if r.valid: n += 1 return n
# Advance by one rendered frame: sim_dt = sim seconds this frame advanced, wall_dt = real seconds.func step(sim_dt: float, wall_dt: float) -> Dictionary: if not running(): return {} _t += sim_dt _wall += wall_dt match state: State.WARMUP: if _t >= warmup_s: _enter(State.SETTLE) State.SETTLE: if _t >= SETTLE_S: _enter(State.SEGMENT) State.SEGMENT: if _t >= seg_s: return _finish_segment() return {}
func sample(frame_ms: float, process_ms: float, render_cpu_ms: float, enemies: int, webs: int, zones: int, live: int) -> bool: if state != State.SEGMENT: return false _frames.append(frame_ms) _proc_sum += process_ms _cpu_sum += render_cpu_ms _enemies_sum += enemies _webs_sum += webs _zones_sum += zones _live_sum += live if live > 0: _live_seen = true if frame_ms > _worst: _worst = frame_ms return true return false
func note_fired(n: int) -> void: if state == State.SEGMENT: _fired += n
func note_frozen() -> void: if state == State.SEGMENT: _frozen = true
func set_worst_recent(tags: String) -> void: _worst_recent = tags
func summary() -> Dictionary: var out := {"A": _side_summary("A"), "B": _side_summary("B")} var d := {} for k in out.A: d[k] = float(out.B[k]) - float(out.A[k]) out["delta"] = d return out
func _side_summary(s: String) -> Dictionary: var cols := {"p50": [] as Array[float], "p95": [] as Array[float], "max": [] as Array[float], "n_over_33": [] as Array[float], "process_ms": [] as Array[float], "render_cpu_ms": [] as Array[float]} for r in rows: if r.valid and r.side == s: for k in cols: (cols[k] as Array[float]).append(float(r[k])) var out := {} for k in cols: out[k] = median(cols[k]) return out
func _enter(s: State) -> void: state = s _t = 0.0 _wall = 0.0 if s == State.SEGMENT: _frames.clear() _proc_sum = 0.0 _cpu_sum = 0.0 _enemies_sum = 0 _webs_sum = 0 _zones_sum = 0 _live_sum = 0 _live_seen = false _fired = 0 _frozen = false _worst = -1.0 _worst_recent = ""
func _finish_segment() -> Dictionary: var n := _frames.size() var sorted := _frames.duplicate() sorted.sort() var over := 0 for f in _frames: if f > OVER_MS: over += 1 var nf := float(maxi(n, 1)) var sim_rate := _t / maxf(_wall, 1e-6) var row := { "seg": seg, "side": side(), "frames": n, "p50": percentile(sorted, 0.5), "p95": percentile(sorted, 0.95), "max": sorted[n - 1] if n > 0 else 0.0, "n_over_33": over, "process_ms": _proc_sum / nf, "render_cpu_ms": _cpu_sum / nf, "enemies": _enemies_sum / nf, "webs": _webs_sum / nf, "zones": _zones_sum / nf, "fired": _fired, "live": _live_sum / nf, "sim_rate": sim_rate, "frozen": _frozen, "worst_recent": _worst_recent, } row["valid"] = n > 0 and sim_rate >= MIN_SIM_RATE and not _frozen and _live_seen rows.append(row) attempts += 1 if row.valid: seg += 1 if seg >= order.size(): state = State.DONE elif attempts >= max_attempts: incomplete = true state = State.DONE else: _enter(State.SETTLE) return row- Step 4: Run, verify it passes
Same command as Step 2. Expected: all 8 tests PASS. If warnings-as-errors flags an inferred type (e.g. var s := v.duplicate()), annotate explicitly (var s: Array[float] = v.duplicate()) per CLAUDE.md’s typed-GDScript note.
- Step 5: Commit
git add render/perf_scenario_core.gd tests/test_perf_scenario_core.gdgit commit -F <msgfile> # "feat(perf): PerfScenarioCore — pure ABBA scheduler + segment stats"Task 2: PerfRig (shared holds/hides) + ZoneRenderer accessors + perf_probe migration
Section titled “Task 2: PerfRig (shared holds/hides) + ZoneRenderer accessors + perf_probe migration”Files:
- Create:
render/perf_rig.gd - Modify:
render/zone_renderer.gd(add accessors next tovar _web_layer:50 /var _zone_layer:312) - Modify:
tools/perf_probe/perf_probe.gd(_processweb block,_hold_zones,_hold_targets,_hide_v2_looks, XP hold) - Test:
tests/test_perf_rig.gd
Interfaces:
-
Consumes: main members
zone_renderer,fx_layer(v2_active(),v2_look(w),active_novas(),nova_active_count()),bolt_renderer,orbit_look;Sim.dev_spawn_custom. -
Produces:
class_name PerfRig extends RefCountedwith constsTARGET_RING := 70.0,TARGET_HP := 1.0e9,V2_ARMS; static funcsarm_ids() -> Array[String],arm_wid(arm: String) -> String,hold_targets(sim: Sim, n: int) -> void,hold_webs(sim: Sim, n: int, fade: float = -1.0) -> void,hold_zones(sim: Sim, n: int) -> void,hold_xp(sim: Sim) -> void,arm_layer(main, arm: String) -> MultiMeshInstance2D,hide_arm(main, arm: String) -> void,live_count(main, arm: String) -> int. ZoneRenderer:func web_layer() -> MultiMeshInstance2D,func zone_layer() -> MultiMeshInstance2D. -
Step 1: Write the failing tests
extends GutTest# PerfRig: the holds/hides shared by tools/perf_probe and the on-device perf scenario.
func _main() -> Node: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") return m
func test_arm_roster() -> void: var ids := PerfRig.arm_ids() for a in ["nova_shockwave", "zones", "webs", "blade", "scythe", "nova", "blaster", "bolts", "orbit"]: assert_has(ids, a) assert_eq(PerfRig.arm_wid("nova_shockwave"), "nova") assert_eq(PerfRig.arm_wid("webs"), "")
func test_hold_webs_zones_targets_xp() -> void: var m = await _main() var s: Sim = m.sim PerfRig.hold_webs(s, 12) PerfRig.hold_zones(s, 5) PerfRig.hold_targets(s, 6) s.player.xp = 50.0 PerfRig.hold_xp(s) assert_eq(s.webs.size(), 12) assert_eq(s.zones.size(), 5) var t := 0 for i in s.enemies.count: if s.enemies.type_id[i] == EnemyPool.TYPE_CUSTOM: t += 1 assert_almost_eq(s.enemies.pos[i].distance_to(s.player.pos), PerfRig.TARGET_RING, 0.01) assert_eq(t, 6) assert_eq(s.player.xp, 0.0) PerfRig.hold_webs(s, 3) assert_eq(s.webs.size(), 3, "shrinks too")
func test_hide_webs_and_zones_layers() -> void: var m = await _main() PerfRig.hold_webs(m.sim, 10) PerfRig.hold_zones(m.sim, 4) m.zone_renderer._process(1.0 / 60.0) assert_eq(PerfRig.live_count(m, "webs"), 10) assert_eq(PerfRig.live_count(m, "zones"), 4) PerfRig.hide_arm(m, "webs") assert_eq(m.zone_renderer.web_layer().multimesh.visible_instance_count, 0) assert_eq(m.zone_renderer.zone_layer().multimesh.visible_instance_count, 4, "only the arm is hidden")
func test_hide_unknown_or_missing_layer_is_safe() -> void: var m = await _main() PerfRig.hide_arm(m, "nova_shockwave") PerfRig.hide_arm(m, "no_such_arm") assert_eq(PerfRig.live_count(m, "no_such_arm"), 0)- Step 2: Run, verify it fails
… -gselect=test_perf_rig.gd → FAIL, PerfRig not found.
- Step 3: Add ZoneRenderer accessors (in
render/zone_renderer.gd, directly under the_zone_layerdeclaration)
# Read-only handles for dev tooling (render/perf_rig.gd hides one layer per frame for perf A/Bs).func web_layer() -> MultiMeshInstance2D: return _web_layer
func zone_layer() -> MultiMeshInstance2D: return _zone_layer- Step 4: Implement
render/perf_rig.gd(hold bodies are perf_probe’s, verbatim apart from takingsim/n)
class_name PerfRigextends RefCounted# Shared perf-rig helpers — DEV ONLY. Used by tools/perf_probe (Mac) and render/perf_scenario.gd# (on device) so both measure the same scene. Holds write dev data into sim arrays every frame; only# ever called from dev tooling, never from the shipped game path, so determinism is untouched.
const TARGET_RING := 70.0 # px from the player: inside every batch-1 weapon's reachconst TARGET_HP := 1.0e9const V2_ARMS: Array[String] = ["blade", "scythe", "nova", "blaster", "bolts", "orbit"]
static func arm_ids() -> Array[String]: var out: Array[String] = ["nova_shockwave", "zones", "webs"] out.append_array(V2_ARMS) return out
# The wid whose fx events count as "fired" for an arm; "" for continuous layers.static func arm_wid(arm: String) -> String: match arm: "nova_shockwave", "nova": return "nova" "blade", "scythe", "blaster": return arm return ""
# n stationary, effectively unkillable TYPE_CUSTOM targets on a ring round P1, re-pinned each frame.static func hold_targets(sim: Sim, n: int) -> void: var idx: Array[int] = [] for i in range(sim.enemies.count): if sim.enemies.type_id[i] == EnemyPool.TYPE_CUSTOM: idx.append(i) var c: Vector2 = sim.player.pos while idx.size() < n: var ni: int = sim.dev_spawn_custom({"hp": TARGET_HP, "speed": 0.0, "contact": 0.0, "radius": 16.0}, c) if ni == -1: break idx.append(ni) for k in range(idx.size()): var ang := TAU * float(k) / float(maxi(idx.size(), 1)) sim.enemies.pos[idx[k]] = c + Vector2(cos(ang), sin(ang)) * TARGET_RING sim.enemies.data[idx[k]] = TARGET_HP # data = HP (EntityPool.add d)
# n on-screen webs in a grid round P1; fade < 0 = varied fades.static func hold_webs(sim: Sim, n: int, fade: float = -1.0) -> void: while sim.webs.size() > n: sim.webs.pop_back() while sim.webs.size() < n: sim.webs.append({"pos": Vector2.ZERO, "dir": Vector2(1, 0), "half_len": 0.0, "radius": 38.0, "remaining": 0.0}) var c: Vector2 = sim.player.pos var cols := 25 for i in n: var gx := i % cols var gy := i / cols sim.webs[i]["pos"] = c + Vector2((gx - cols * 0.5) * 76.0, (gy - n / cols * 0.5) * 66.0) sim.webs[i]["remaining"] = Sim.WEB_DURATION * (fade if fade >= 0.0 else 0.3 + 0.7 * float(i % 7) / 6.0)
# n reaction-terrain zones in a grid round P1; elements cycle 0..4, fades 0.35..1.static func hold_zones(sim: Sim, n: int) -> void: while sim.zones.size() > n: sim.zones.pop_back() while sim.zones.size() < n: sim.zones.append({"pos": Vector2.ZERO, "radius": ElementalSystem.ZONE_RADIUS, "remaining": 0.0, "element": 0, "dps": 0.0}) var c: Vector2 = sim.player.pos var cols := 8 for i in n: sim.zones[i]["pos"] = c + Vector2((i % cols - cols * 0.5 + 0.5) * 200.0, (i / cols - n / cols * 0.5 + 0.5) * 120.0) sim.zones[i]["element"] = i % 5 sim.zones[i]["radius"] = ElementalSystem.ZONE_RADIUS * (0.6 + 0.4 * float(i % 3) / 2.0) sim.zones[i]["remaining"] = ElementalSystem.ZONE_DURATION * (0.35 + 0.65 * float(i % 4) / 3.0)
# Every pilot's XP to 0 so no level-up panel freezes a sample or changes the build mid-comparison.static func hold_xp(sim: Sim) -> void: for pl in sim.pilots: pl.xp = 0.0
static func arm_layer(main, arm: String) -> MultiMeshInstance2D: match arm: "zones": return main.zone_renderer.zone_layer() if main.zone_renderer != null else null "webs": return main.zone_renderer.web_layer() if main.zone_renderer != null else null "bolts": return main.bolt_renderer "orbit": return main.orbit_look "nova_shockwave": if main.fx_layer != null and main.fx_layer.v2_active(): return main.fx_layer.v2_look("nova") return null if main.fx_layer != null and V2_ARMS.has(arm): return main.fx_layer.v2_look(arm) return null
# Call from RenderingServer.frame_pre_draw, every frame (renderers re-set their counts each _process).# v1 Supernova (no v2 look) is pooled PlasmaNovaNodes: hidden by visibility, which sticks until the# node's next reset. That also hides Scar Tissue novas (same node type) — acceptable for a dev A/B.static func hide_arm(main, arm: String) -> void: var layer := arm_layer(main, arm) if layer != null: layer.multimesh.visible_instance_count = 0 if arm == "nova_shockwave" and main.fx_layer != null and not main.fx_layer.v2_active(): for nv in main.fx_layer.active_novas(): (nv as CanvasItem).visible = false
static func live_count(main, arm: String) -> int: if arm == "nova_shockwave" and main.fx_layer != null and not main.fx_layer.v2_active(): return main.fx_layer.nova_active_count() var layer := arm_layer(main, arm) if layer == null or not layer.visible: return 0 return layer.multimesh.visible_instance_count-
Step 5: Run, verify tests pass (
-gselect=test_perf_rig.gd). If the v2 nova look is null headless,test_hide_unknown_or_missing_layer_is_safestill passes (null-guarded). -
Step 6: Migrate perf_probe onto PerfRig
In tools/perf_probe/perf_probe.gd:
-
_process: replace theif _hold_xp:loop withif _hold_xp: PerfRig.hold_xp(main.sim); replace the inline web block (fromvar s = main.sim/while s.webs.size() > _websthrough theremainingassignment) withPerfRig.hold_webs(main.sim, _webs, _web_fade)(keep the precedingif _webs <= 0: return). -
_hold_zones()body →PerfRig.hold_zones(main.sim, _zones). -
_hold_targets()body →PerfRig.hold_targets(main.sim, _targets). -
_hide_v2_looks()body → keep themain == null or main.fx_layer == nullguard, thenfor w in _hide_v2: PerfRig.hide_arm(main, w). -
Delete the now-unused
TARGET_RING/TARGET_HPconsts there if nothing else references them (grep first). -
Step 7: Smoke-run perf_probe windowed (behaviour must be unchanged)
godot --path . --rendering-method mobile --always-on-top res://tools/perf_probe/perf_probe.tscn -- --mode=survival --weapons=blaster,orbit,blade,scythe,nova --evolve --targets=12 --webs=50 --zones=4 --hide-v2=nova --ab=1 --no-breakdowngit diff --quiet project.godot || git checkout -- project.godotExpected: runs to completion, prints an ab pair= line with fired= > 0 for nova and a MEDIAN line; no script errors.
- Step 8: Commit
git add render/perf_rig.gd render/zone_renderer.gd tools/perf_probe/perf_probe.gd tests/test_perf_rig.gd → refactor(perf): extract PerfRig from perf_probe; ZoneRenderer layer accessors
Task 3: PerfScenario node + main wiring
Section titled “Task 3: PerfScenario node + main wiring”Files:
- Create:
render/perf_scenario.gd - Modify:
main.gd— member nearvar control_client(:119); arms inapply_dev_command(:2764);build_status(:2895) andbuild_caps(:2945) literals; event forwarding next totelemetry.note_events(render_events)(:1753) - Test:
tests/test_perf_scenario.gd
Interfaces:
-
Consumes:
PerfScenarioCore(Task 1),PerfRig(Task 2), main memberssim,telemetry(.hitches.recent_tags()),event_queue(push(t, d),flush_now()),_paused_for_levelup,_paused_for_dimension_choice,_paused_for_menu;Telemetry.render_times(vps: Array[RID]) -> Dictionary. -
Produces:
class_name PerfScenario extends Nodewithconst PRESETS,static func preset_caps() -> Array[Dictionary],static func normalize(p: Dictionary) -> Dictionary({"error": String}or the clamped params),var core: PerfScenarioCore,var push_event: Callable,var note: String,func begin(m, params: Dictionary) -> String(“” or error),func cancel(reason: String = "cancelled") -> void,func note_events(evs: Array) -> void,func status() -> Dictionary. Main:var perf_scenario: PerfScenario, command kindsperf_scenario/perf_scenario_cancel, caps keysperf_presets/perf_arms, status keyperf_scenario. -
Step 1: Write the failing tests
extends GutTest# The on-device perf scenario: presets, params, the full command -> segments -> events -> teardown path.
const DT := 1.0 / 60.0
func _main() -> Node: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame m._on_mode_chosen("survival") return m
# Drive frames by hand: sim tick, the zone layer (so held webs are live), then the scenario.func _drive(m, max_frames: int = 2000) -> void: for _i in max_frames: if m.perf_scenario == null or not m.perf_scenario.core.running(): return m._physics_process(DT) m.zone_renderer._process(DT) m.perf_scenario._process(DT)
func test_normalize_clamps_and_validates() -> void: var p := PerfScenario.normalize({"preset": "supernova", "arm": "webs", "segments": 7, "seg_s": 999, "webs": 9999}) assert_eq(p.segments, 6, "even, clamped") assert_eq(p.seg_s, 60.0) assert_eq(p.webs, 400) assert_eq(p.warmup_s, 5.0, "default") assert_true(PerfScenario.normalize({"preset": "nope", "arm": "webs"}).has("error")) assert_true(PerfScenario.normalize({"preset": "supernova", "arm": "nope"}).has("error"))
func test_presets_use_real_weapons_and_elements() -> void: for name in PerfScenario.PRESETS: var pr: Dictionary = PerfScenario.PRESETS[name] for wid in pr.weapons: assert_has(Sim.dev_weapon_ids(), wid, "%s: %s" % [name, wid]) for el in pr.crystals: assert_has(Sim.dev_crystal_elements(), el, "%s: %s" % [name, el])
func test_evolve_presets_end_evolved() -> void: for name in PerfScenario.PRESETS: var pr: Dictionary = PerfScenario.PRESETS[name] if not pr.evolve: continue var m = await _main() m.apply_dev_command({"kind": "perf_scenario", "preset": name, "arm": "webs", "segments": 2, "seg_s": 2, "warmup_s": 0}) for wid in pr.weapons: assert_true(WeaponVfx.is_evolved(m.sim.player.arsenal, wid), "%s: %s evolved" % [name, wid]) m.apply_dev_command({"kind": "perf_scenario_cancel"})
func test_full_run_emits_perf_seg_events_and_tears_down() -> void: var m = await _main() m.apply_dev_command({"kind": "perf_scenario", "preset": "supernova", "arm": "webs", "segments": 2, "seg_s": 2, "warmup_s": 0, "webs": 20}) assert_not_null(m.perf_scenario) var pushed: Array = [] m.perf_scenario.push_event = func(t: String, d: Dictionary) -> String: pushed.append([t, d]) return "" assert_true(m.sim.player.dev_invuln) assert_true(m.sim.dev_suppress_spawns) _drive(m) var st: Dictionary = m.build_status().perf_scenario assert_eq(st.state, "done") assert_eq(st.valid_count, 2) assert_eq(pushed.size(), 2) assert_eq(pushed[0][0], "perf_seg") for k in ["scenario_id", "preset", "arm", "seg", "side", "valid", "p50", "p95", "max", "n_over_33", "render_cpu_ms", "process_ms", "worst_recent"]: assert_true(pushed[0][1].has(k), k) assert_false(m.sim.player.dev_invuln, "restored") assert_false(m.sim.dev_suppress_spawns, "restored") assert_eq(m.sim.webs.size(), 0, "held webs dropped")
func test_second_start_refused_and_cancel_restores() -> void: var m = await _main() m.apply_dev_command({"kind": "perf_scenario", "preset": "baseline", "arm": "webs", "segments": 2, "seg_s": 2, "webs": 5}) var first = m.perf_scenario m.apply_dev_command({"kind": "perf_scenario", "preset": "baseline", "arm": "zones"}) assert_eq(m.perf_scenario, first) assert_eq(m.build_status().perf_scenario.note, "already running") m.apply_dev_command({"kind": "perf_scenario_cancel"}) assert_eq(m.build_status().perf_scenario.state, "cancelled") assert_false(m.sim.player.dev_invuln)
func test_caps_publish_presets_and_arms() -> void: var m = await _main() var caps: Dictionary = m.build_caps() assert_has(caps.perf_arms, "nova_shockwave") var names := [] for p in caps.perf_presets: names.append(p.name) assert_has(names, "supernova") assert_null(m.build_status().perf_scenario, "null before any run")-
Step 2: Run, verify it fails (
-gselect=test_perf_scenario.gd) →PerfScenarionot found. -
Step 3: Implement
render/perf_scenario.gd
class_name PerfScenarioextends Node# On-device perf scenario — DEV ONLY (main.apply_dev_command "perf_scenario", dev builds only).# Spec: docs/superpowers/specs/2026-10-01-on-device-perf-scenario-design.md. Resets P1, clears the# arena, turns on invuln + paused spawns, grants a canned preset through the REAL crystal-threshold# path, then ABBA-alternates hiding one render layer (PerfRig.hide_arm) in sim-timed segments. Each# finished segment is pushed as a "perf_seg" telemetry event; status() feeds the console panel.# Render-only: nothing here changes what the sim computes beyond the dev holds.
# weapons: granted on top of the starting blaster. crystals: element -> count, via dev_add_crystals# (rules are independent, so the evolve rung fires on its own count — WeaponThresholds.TABLE).const PRESETS := { "baseline": {"weapons": [], "crystals": {}, "evolve": false}, "supernova": {"weapons": ["nova"], "crystals": {"fire": 12, "light": 18}, "evolve": true}, "batch1_evolved": {"weapons": ["blaster", "orbit", "blade", "scythe", "nova"], "crystals": {"fire": 12, "void": 18, "light": 18, "blood": 18}, "evolve": true},}const DEFAULTS := {"segments": 8, "seg_s": 10.0, "warmup_s": 5.0, "targets": 12, "webs": 0, "zones": 0}
var core := PerfScenarioCore.new()var push_event: Callablevar note := ""var params := {}var scenario_id := ""
var _mainvar _sim: Simvar _saved_invuln := falsevar _saved_paused := falsevar _armed := false # setup applied; teardown pendingvar _last_run_time := 0.0var _fired_wid := ""
static func preset_caps() -> Array[Dictionary]: var out: Array[Dictionary] = [] for name in PRESETS: out.append({"name": name, "weapons": PRESETS[name].weapons}) return out
static func normalize(p: Dictionary) -> Dictionary: var preset := String(p.get("preset", "")) var arm := String(p.get("arm", "")) if not PRESETS.has(preset): return {"error": "unknown preset '%s'" % preset} if not PerfRig.arm_ids().has(arm): return {"error": "unknown arm '%s'" % arm} var segs := clampi(int(p.get("segments", DEFAULTS.segments)), 2, 20) segs -= segs % 2 return { "preset": preset, "arm": arm, "segments": segs, "seg_s": clampf(float(p.get("seg_s", DEFAULTS.seg_s)), 2.0, 60.0), "warmup_s": clampf(float(p.get("warmup_s", DEFAULTS.warmup_s)), 0.0, 30.0), "targets": clampi(int(p.get("targets", DEFAULTS.targets)), 0, 400), "webs": clampi(int(p.get("webs", DEFAULTS.webs)), 0, 400), "zones": clampi(int(p.get("zones", DEFAULTS.zones)), 0, 400), }
func begin(m, raw: Dictionary) -> String: _main = m var p := normalize(raw) if p.has("error"): note = p.error return note if m.sim.ruleset != Sim.RULESET_SURVIVAL: note = "survival runs only" return note params = p _sim = m.sim scenario_id = "%d-%08x" % [Sim_Const.BUILD, randi()] # render-side randi, never sim.rng _fired_wid = PerfRig.arm_wid(p.arm) # Setup through the same dev commands the console uses (reset_upgrades re-applies meta stats). m.apply_dev_command({"kind": "reset_upgrades"}) _sim.dev_clear_enemies() _saved_invuln = _sim.player.dev_invuln _saved_paused = _sim.dev_suppress_spawns _sim.player.dev_invuln = true _sim.dev_suppress_spawns = true var pr: Dictionary = PRESETS[p.preset] for wid in pr.weapons: _sim.dev_force_grant_weapon(_sim.player, wid) for el in pr.crystals: var left: int = pr.crystals[el] while left > 0: # dev_add_crystals clamps one call to DEV_CRYSTAL_MAX_STEP var step := mini(left, 100) _sim.dev_add_crystals(el, step) left -= step PerfRig.hold_xp(_sim) _armed = true _last_run_time = _sim.run_time if not push_event.is_valid() and m.event_queue != null: push_event = m.event_queue.push RenderingServer.viewport_set_measure_render_time(get_viewport().get_viewport_rid(), true) RenderingServer.frame_pre_draw.connect(_pre_draw) core.start(p.segments, p.seg_s, p.warmup_s) note = "" return ""
func cancel(reason: String = "cancelled") -> void: core.cancel() note = reason _teardown()
# Forwarded by main._process (it drains sim.render_fx_events before this node's _process runs).func note_events(evs: Array) -> void: if _fired_wid == "": return var n := 0 for ev in evs: if String(ev.get("wid", "")) == _fired_wid: n += 1 core.note_fired(n)
func status() -> Dictionary: return { "state": core.state_name(), "scenario_id": scenario_id, "preset": params.get("preset", ""), "arm": params.get("arm", ""), "seg": core.seg, "of": core.order.size(), "side": core.side(), "valid_count": core.valid_count(), "rows": core.rows, "summary": core.summary(), "incomplete": core.incomplete, "note": note, }
func _process(dt: float) -> void: if not core.running() or _main == null: return if _main.sim != _sim or _sim.game_over: cancel("run ended") return # Holds every frame (the sim ticks them down / moves the player). PerfRig.hold_xp(_sim) if params.targets > 0: PerfRig.hold_targets(_sim, params.targets) if params.webs > 0: PerfRig.hold_webs(_sim, params.webs) if params.zones > 0: PerfRig.hold_zones(_sim, params.zones) if _main._paused_for_levelup or _main._paused_for_dimension_choice or _main._paused_for_menu: core.note_frozen() var rt: Dictionary = Telemetry.render_times([get_viewport().get_viewport_rid()]) var cpu: Variant = rt.get("render_cpu_ms") var worse := core.sample(dt * 1000.0, Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0, float(cpu) if cpu != null else 0.0, _sim.enemies.count, _sim.webs.size(), _sim.zones.size(), PerfRig.live_count(_main, params.arm)) if worse and _main.telemetry != null: core.set_worst_recent(_main.telemetry.hitches.recent_tags()) var sim_dt: float = _sim.run_time - _last_run_time _last_run_time = _sim.run_time var row := core.step(sim_dt, dt) if not row.is_empty(): _push(row) if not core.running(): note = "incomplete: too many invalid segments" if core.incomplete else "" _teardown()
func _pre_draw() -> void: if core.hidden_now() and _main != null: PerfRig.hide_arm(_main, params.arm)
func _push(row: Dictionary) -> void: if not push_event.is_valid(): return var d := row.duplicate() d.merge({"scenario_id": scenario_id, "preset": params.preset, "arm": params.arm, "seg_s": params.seg_s}) push_event.call("perf_seg", d)
func _teardown() -> void: if not _armed: return _armed = false if RenderingServer.frame_pre_draw.is_connected(_pre_draw): RenderingServer.frame_pre_draw.disconnect(_pre_draw) if _sim != null: _sim.player.dev_invuln = _saved_invuln _sim.dev_suppress_spawns = _saved_paused if _main != null and _main.sim == _sim and not _sim.game_over: _sim.dev_clear_enemies() # drops held targets/webs/zones if _main != null and _main.event_queue != null and push_event == _main.event_queue.push: _main.event_queue.flush_now()
func _exit_tree() -> void: # Swept by _new_run/_return_to_menu: never leave the frame_pre_draw hide connected. if core.running(): core.cancel() _teardown()- Step 4: Wire main.gd
Member (next to var control_client: ControlClient, :119):
var perf_scenario: PerfScenario # dev-only on-device perf A/B (render/perf_scenario.gd); null until first runapply_dev_command arms (inside the match, near add_crystals):
"perf_scenario": if not BuildConfig.dev_tools(): return if perf_scenario != null and is_instance_valid(perf_scenario) and perf_scenario.core.running(): perf_scenario.note = "already running" return if perf_scenario != null and is_instance_valid(perf_scenario): perf_scenario.queue_free() perf_scenario = PerfScenario.new() add_child(perf_scenario) perf_scenario.begin(self, cmd) "perf_scenario_cancel": if perf_scenario != null and is_instance_valid(perf_scenario): perf_scenario.cancel()build_status literal, add:
"perf_scenario": perf_scenario.status() if perf_scenario != null and is_instance_valid(perf_scenario) else null,build_caps literal, add:
"perf_presets": PerfScenario.preset_caps(), "perf_arms": PerfRig.arm_ids(),Event forwarding, directly after telemetry.note_events(render_events) (:1753), at the same indentation:
if perf_scenario != null and is_instance_valid(perf_scenario): perf_scenario.note_events(render_events)-
Step 5: Run, verify tests pass (
-gselect=test_perf_scenario.gd). Iftest_full_run…reports valid_count < 2, printm.build_status().perf_scenario.rowsand checksim_rate/frozen/live— the likely cause is the zone layer not beingvisibleheadless; fix the cause, don’t loosen the validity rule. -
Step 6: Run the full suite + gate
scripts/check-test-count.sh then bash scripts/gate.sh. Expected: green; determinism files byte-identical (nothing here runs without the command).
- Step 7: Commit —
feat(perf): on-device perf scenario command (PerfScenario + main wiring)
Task 4: Panel Perf tab
Section titled “Task 4: Panel Perf tab”Files:
- Modify:
control/src/panel.html.js— pane markup (next to#tab-crystals,:213),:354),TABS(loadCaps()(:415),:307)applyStatus()( - Test:
control/test/panel.test.mjs(addrenderPerf, updatePerf, perfCommandto theloadPanel()return list)
Interfaces:
-
Consumes: caps
perf_presets: [{name, weapons}],perf_arms: [String]; statusperf_scenario(Task 3status()shape, or null);send(cmd). -
Produces:
renderPerf(presets, arms),perfCommand() -> {kind:'perf_scenario', preset, arm, segments, seg_s, warmup_s, targets, webs, zones},updatePerf(ps). -
Step 1: Write the failing tests (append to
control/test/panel.test.mjs; reuse itsloadPanel,btn,buttonsOfhelpers)
test('perf tab builds from caps and sends perf_scenario', async () => { const { api, els, sent } = loadPanel(); api.renderPerf([{ name: 'baseline', weapons: [] }, { name: 'supernova', weapons: ['nova'] }], ['nova_shockwave', 'webs']); const cmd = api.perfCommand(); assert.deepEqual(cmd, { kind: 'perf_scenario', preset: 'baseline', arm: 'nova_shockwave', segments: 8, seg_s: 10, warmup_s: 5, targets: 12, webs: 0, zones: 0 }); btn(els['perf-form'], 'Run').onclick(); btn(els['perf-form'], 'Cancel').onclick(); await new Promise((r) => setImmediate(r)); assert.equal(sent[0].kind, 'perf_scenario'); assert.deepEqual(sent[1], { kind: 'perf_scenario_cancel' });});
test('perf status renders progress, one row per segment, and the summary', () => { const { api, els } = loadPanel(); api.updatePerf({ state: 'segment', scenario_id: '236-abc', preset: 'supernova', arm: 'webs', seg: 1, of: 8, side: 'B', valid_count: 1, incomplete: false, note: '', rows: [{ seg: 0, side: 'A', valid: true, p50: 16.7, p95: 20, max: 30, n_over_33: 0, render_cpu_ms: 0.7, enemies: 12, fired: 3 }], summary: { A: { p50: 16.7, p95: 20, max: 30, n_over_33: 0 }, B: { p50: 0, p95: 0, max: 0, n_over_33: 0 }, delta: { p50: -16.7, p95: -20, max: -30, n_over_33: 0 } } }); assert.match(els['perf-progress'].textContent, /seg 2\/8 · B/); assert.match(els['perf-progress'].textContent, /236-abc/); // header + 1 segment row + 3 summary rows (A, B, delta) assert.equal(els['perf-table'].children.length, 5); api.updatePerf(null); assert.equal(els['perf-progress'].textContent, 'idle');});If the fake DOM’s btn(el, label) helper only searches direct children, put the Run/Cancel buttons directly in #perf-form (the implementation below does).
-
Step 2: Run, verify it fails —
node --test control/test/panel.test.mjs→api.renderPerf is not a function. -
Step 3: Implement
Pane markup (after the #tab-crystals pane):
<div class="pane" id="tab-perf"> <div class="card"> <h2>Perf scenario</h2> <p class="hint">Runs on the device: resets P1, clears + pauses spawns, invuln, grants the preset via crystals, then ABBA-alternates hiding the arm (render-only). Each segment also lands in D1 as a perf_seg event.</p> <div id="perf-form"></div> <div id="perf-progress" class="hint">idle</div> <div id="perf-table"></div> </div></div>TABS: insert ['perf','Perf'] before ['readout','Readout'].
loadCaps(): after renderCrystals(...) add renderPerf(caps.perf_presets||[], caps.perf_arms||[]);
applyStatus(s): after updateCrystalRows(s.crystals); add updatePerf(s.perf_scenario);
Functions (next to renderCrystals):
const PERF_NUMS = [['segments',8],['seg_s',10],['warmup_s',5],['targets',12],['webs',0],['zones',0]];let _perf = {};function renderPerf(presets, arms){ const wrap = document.getElementById('perf-form'); wrap.innerHTML=''; _perf = {}; const sel = (key, opts) => { const s = document.createElement('select'); for(const o of opts){ const op=document.createElement('option'); op.value=String(o); op.textContent=String(o); s.appendChild(op); } s.value = String(opts[0] ?? ''); _perf[key] = s; wrap.appendChild(s); }; sel('preset', presets.map((p) => p.name)); sel('arm', arms); for(const [k, def] of PERF_NUMS){ const i = document.createElement('input'); i.type='number'; i.value=String(def); i.title=k; i.placeholder=k; _perf[k] = i; wrap.appendChild(i); } const run = document.createElement('button'); run.textContent='Run'; run.onclick=()=>send(perfCommand()); const stop = document.createElement('button'); stop.textContent='Cancel'; stop.onclick=()=>send({kind:'perf_scenario_cancel'}); wrap.appendChild(run); wrap.appendChild(stop);}function perfCommand(){ const c = {kind:'perf_scenario', preset:_perf.preset.value, arm:_perf.arm.value}; for(const [k] of PERF_NUMS) c[k] = Number(_perf[k].value); return c;}const PERF_COLS = ['seg','side','valid','p50','p95','max','n_over_33','render_cpu_ms','enemies','fired'];const fmt = (v) => typeof v === 'number' ? (Number.isInteger(v) ? String(v) : v.toFixed(1)) : String(v ?? '');function perfRow(cells){ const r = document.createElement('div'); r.className='row'; r.style.margin='0'; for(const c of cells){ const d=document.createElement('div'); d.className='wep-state'; d.textContent=fmt(c); r.appendChild(d); } return r;}function updatePerf(ps){ const prog = document.getElementById('perf-progress'); const table = document.getElementById('perf-table'); table.innerHTML=''; if(!ps){ prog.textContent='idle'; return; } const bits = [ps.state, `seg ${Math.min(ps.seg+1, ps.of)}/${ps.of} · ${ps.side||'-'}`, `${ps.valid_count} valid`, ps.scenario_id]; if(ps.incomplete) bits.push('INCOMPLETE'); if(ps.note) bits.push(ps.note); prog.textContent = bits.join(' · '); table.appendChild(perfRow(PERF_COLS)); for(const row of ps.rows||[]) table.appendChild(perfRow(PERF_COLS.map((k) => k==='valid' ? (row.valid?'✓':'✗') : row[k]))); const sm = ps.summary||{}; for(const side of ['A','B','delta']){ const s = sm[side]||{}; table.appendChild(perfRow([side==='delta'?'B−A':side+' med','','',s.p50,s.p95,s.max,s.n_over_33,'','',''])); }}-
Step 4: Run, verify tests pass —
node --test control/test/panel.test.mjs(all, including the existingexactly one tab pane is visible at a time). -
Step 5: Verify the panel in a real browser (CLAUDE.md lesson: a string smoke never caught the pane-reveal bug). Serve or open the generated panel HTML headless (Playwright/
isolated-chrome-testing), click the Perf tab, confirm the pane shows and the form renders from a stub caps object. Deploy the control Worker only after Task 3 is on main:cd control && CLOUDFLARE_API_TOKEN="$CF_LUMARA_DEPLOY_TOKEN" CLOUDFLARE_ACCOUNT_ID="$CF_ACCOUNT_ID" npx wrangler deploy(source~/.secretsin the same command). -
Step 6: Commit —
feat(control): Perf tab — run/cancel the on-device perf scenario, live A/B table
Task 5: Docs + release item + on-device acceptance
Section titled “Task 5: Docs + release item + on-device acceptance”Files:
-
Modify:
docs/architecture/dev-tools.md(new bullet after line 23, the last Remote Playtest Console bullet) -
Modify:
docs/RELEASE-1.0.1.md(tick the “On-device perf scenario” item) -
Step 1: Add the dev-tools bullet
- **Perf scenario (2026-10-01) — on-device render A/B, Perf tab.** `{kind:"perf_scenario", preset, arm, segments, seg_s, warmup_s, targets, webs, zones}` creates `render/perf_scenario.gd` (dev builds only, survival only): `reset_upgrades` + clear + invuln + paused spawns, a canned preset granted through the real crystal thresholds (`PerfScenario.PRESETS`), then ABBA segments timed in sim seconds, the B side hiding one layer at `frame_pre_draw` (`PerfRig.hide_arm`: `nova_shockwave`, `zones`, `webs`, any v2 look). Segments are rejected and re-run (sim < 90% of wall time, a panel froze main, the arm's look never live), giving up at 2× segments. Each segment → one `perf_seg` event (generic `events` table, JSON `data`; query `json_extract(data,'$.scenario_id')`); `status().perf_scenario` drives the panel table. Holds/hides are shared with `tools/perf_probe` via `render/perf_rig.gd`, so Mac and device measure the same scene. `frame_ms` = main-thread frame delta (≈ GPU time on a GPU-bound device); `process_ms` = `TIME_PROCESS`; `render_cpu_ms` from `Telemetry.render_times`. Main forwards `render_events` to it because main drains `sim.render_fx_events` before children run.-
Step 2: Tick the release item — append
✅ DONE (<sha of Task 3 commit>)to the “On-device perf scenario in the Remote Playtest Console” line. -
Step 3: Commit + push —
docs(perf): perf scenario in dev-tools + release tick;git push origin main. -
Step 4: On-device acceptance (needs Chris) — after the next dev install (BUILD 236+ via
scripts/dev/install-dev.sh): arm the console, Perf tab →supernova×nova_shockwave, defaults. Accept when 8 valid segments show in the panel table andSELECT count(*) FROM events WHERE type='perf_seg' AND json_extract(data,'$.scenario_id')='<id>'returns 8 (or more, with re-runs).
Execution handoff
Section titled “Execution handoff”- Decisions Chris made (brainstorm 2026-10-01): on-device schedule (not panel-driven); canned
presets, not current run; results live in panel + D1, no new inter-Worker route; arms = Supernova
shockwave, zones, webs, any v2 look; defaults 8 × 10 s, 5 s warmup, XP held at 0. Rejected:
copying helpers instead of the shared rig (drift); reusing perf_probe’s scene on device; sim-side
arms like
REMNANT_FRAC(breaks render-only); panel cross-build history and ABCD runs (YAGNI). - Authorized: implementing this plan, committing + pushing to
main. Needs Chris’s same-day OK: the control Worker deploy (Task 4 Step 5), any device install / BUILD bump (Task 5 Step 4 is his physical ATV run). - Already done outside the plan: spec
992a0d6eon main; this plan + spec corrections committed with it. Nothing deployed. - Unrelated open item: dev-channel patch
b235-dev-001(WeaponNova.REMNANT_FRAC=0.1) is still live onbuild-235@devand also serves dev 236+. After Chris’s ATV run against it: compare post-evolutionmax_frame_ms(config_id='b235-dev-001') vs baseline sessionb72fe12d1a1d99f4, then revert ({}toremote-config/build-235.json,node scripts/publish-config.mjs 235 --channel dev). Do not ship Task 5’s on-device run on 236 while that patch is live without noting it — it changes the Supernova crater size. - Environment gotchas: the shared main checkout
~/Claude/bullet-heavenis on ANOTHER session’s branch (rc5-batch4-enemy-tunables, uncommitted work) — do not commit there; work in a worktree offorigin/main(planning used a detached worktree in the session scratchpad). Runbh-sdd-concurrentbefore Task 1. Any windowedgodot --path .run (Task 2 Step 7) →git checkout -- project.godotafter. - Next action:
superpowers:subagent-driven-developmenton this plan from Task 1, in a fresh worktree offorigin/main, code agents withmodel: "opus".