Skip to content

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 follow bh-sdd-concurrent (other sessions share main) and bh-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).

  • Dev command dicts key on "kind" (match String(cmd.get("kind", ""))), never "cmd".
  • /sim is untouched: no new sim code. Everything here is render/dev side. Determinism baselines must not move.
  • Dev-only: the perf_scenario arm returns immediately unless BuildConfig.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_time deltas), never frame counts.
  • Render observers read events forwarded from main._process (render_events), because main drains sim.render_fx_events before its children’s _process runs.
  • 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: segments 2-20 and even, seg_s 2-60, warmup_s 0-30, targets/webs/zones 0-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 main after 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 . --import first). Full suite: scripts/check-test-count.sh (never pipe a raw run into context). Gate: bash scripts/gate.sh.
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 RefCounted with enum 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 PerfScenarioCore
extends 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.8
const MIN_SIM_RATE := 0.9
const OVER_MS := 33.0
const STATE_NAMES := ["idle", "warmup", "settle", "segment", "done", "cancelled"]
var state: State = State.IDLE
var order: Array[String] = []
var seg := 0 # index into order of the current (or next) segment
var attempts := 0
var max_attempts := 0
var seg_s := 10.0
var warmup_s := 5.0
var rows: Array[Dictionary] = []
var incomplete := false
var _t := 0.0 # sim seconds in the current phase
var _wall := 0.0 # wall seconds in the current phase
var _frames: Array[float] = []
var _proc_sum := 0.0
var _cpu_sum := 0.0
var _enemies_sum := 0
var _webs_sum := 0
var _zones_sum := 0
var _live_sum := 0
var _live_seen := false
var _fired := 0
var _frozen := false
var _worst := -1.0
var _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
Terminal window
git add render/perf_scenario_core.gd tests/test_perf_scenario_core.gd
git 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 to var _web_layer :50 / var _zone_layer :312)
  • Modify: tools/perf_probe/perf_probe.gd (_process web 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 RefCounted with consts TARGET_RING := 70.0, TARGET_HP := 1.0e9, V2_ARMS; static funcs arm_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_layer declaration)
# 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 taking sim/n)
class_name PerfRig
extends 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 reach
const TARGET_HP := 1.0e9
const 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_safe still passes (null-guarded).

  • Step 6: Migrate perf_probe onto PerfRig

In tools/perf_probe/perf_probe.gd:

  • _process: replace the if _hold_xp: loop with if _hold_xp: PerfRig.hold_xp(main.sim); replace the inline web block (from var s = main.sim / while s.webs.size() > _webs through the remaining assignment) with PerfRig.hold_webs(main.sim, _webs, _web_fade) (keep the preceding if _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 the main == null or main.fx_layer == null guard, then for w in _hide_v2: PerfRig.hide_arm(main, w).

  • Delete the now-unused TARGET_RING / TARGET_HP consts there if nothing else references them (grep first).

  • Step 7: Smoke-run perf_probe windowed (behaviour must be unchanged)

Terminal window
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-breakdown
git diff --quiet project.godot || git checkout -- project.godot

Expected: 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


Files:

  • Create: render/perf_scenario.gd
  • Modify: main.gd — member near var control_client (:119); arms in apply_dev_command (:2764); build_status (:2895) and build_caps (:2945) literals; event forwarding next to telemetry.note_events(render_events) (:1753)
  • Test: tests/test_perf_scenario.gd

Interfaces:

  • Consumes: PerfScenarioCore (Task 1), PerfRig (Task 2), main members sim, 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 Node with const 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 kinds perf_scenario / perf_scenario_cancel, caps keys perf_presets / perf_arms, status key perf_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) → PerfScenario not found.

  • Step 3: Implement render/perf_scenario.gd

class_name PerfScenario
extends 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: Callable
var note := ""
var params := {}
var scenario_id := ""
var _main
var _sim: Sim
var _saved_invuln := false
var _saved_paused := false
var _armed := false # setup applied; teardown pending
var _last_run_time := 0.0
var _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 run

apply_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). If test_full_run… reports valid_count < 2, print m.build_status().perf_scenario.rows and check sim_rate/frozen/live — the likely cause is the zone layer not being visible headless; 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)

Files:

  • Modify: control/src/panel.html.js — pane markup (next to #tab-crystals, :213), TABS (:354), loadCaps() (:415), applyStatus() (:307)
  • Test: control/test/panel.test.mjs (add renderPerf, updatePerf, perfCommand to the loadPanel() return list)

Interfaces:

  • Consumes: caps perf_presets: [{name, weapons}], perf_arms: [String]; status perf_scenario (Task 3 status() 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 its loadPanel, btn, buttonsOf helpers)

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 existing exactly 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 ~/.secrets in 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 and SELECT count(*) FROM events WHERE type='perf_seg' AND json_extract(data,'$.scenario_id')='<id>' returns 8 (or more, with re-runs).

  • 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 992a0d6e on 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 on build-235@dev and also serves dev 236+. After Chris’s ATV run against it: compare post-evolution max_frame_ms (config_id='b235-dev-001') vs baseline session b72fe12d1a1d99f4, then revert ({} to remote-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-heaven is on ANOTHER session’s branch (rc5-batch4-enemy-tunables, uncommitted work) — do not commit there; work in a worktree off origin/main (planning used a detached worktree in the session scratchpad). Run bh-sdd-concurrent before Task 1. Any windowed godot --path . run (Task 2 Step 7) → git checkout -- project.godot after.
  • Next action: superpowers:subagent-driven-development on this plan from Task 1, in a fresh worktree off origin/main, code agents with model: "opus".