The instrumentation and benchmark harness built during the 2026-07 performance audit. Two layers: an in-game diagnostic mode any player can switch on (?perfDebug=1), and a repeatable Playwright benchmark harness on top of it (npm run perf:bench / perf:report). The audit's measured conclusions live in perf-findings.json and the generated perf-report.html; this page documents the tools, not the results.
Adding ?perfDebug=1 to the URL constructs a FramePerfLogger (src/engine/perfDebug.ts) alongside the engine. Without the flag, none of this code runs — every engine call site is this.perf?.….
What it logs (via plain console.log, deliberately, so the lines render in the in-game console sidebar and ride along in a player's screen recording — no DevTools needed):
[perf] env: …once at startup — cores, memory, DPR, screen/viewport, user agent[perf] level: …after each map generation — map size, canvas size, enemy/mine counts[perf] tick|SLOW <raw>ms (~<fps>fps) unacct=<ms> mouse=<n>/f | <phase>=<ms> …— one frame's full phase breakdown, nine phases: input-poll, input-actions, sim, viewmodel, firing, raycast-walls, billboards+targeting, particle-effects, hud.SLOWfires for frames under 45fps (rate-limited to one per 250ms);tickis a periodic baseline every 2s. (The rate-limit clock accumulates the passed raw deltas, so under a directadvance()driver — replay viewer, headless — those intervals are simulation time, not wall time; the stats ring below is unaffected.)unacctis the key diagnostic: raw frame delta minus the sum of measured phases — GC, compositor, or the browser not scheduling the rAF promptly. A slow frame with small phases and largeunacctis stalling outside game code.[perf] state: …paired with each of the above — entity counts, ammo, weapon, audio state, heap
Machine-readable side channel: the logger also exposes window.__codeensteinPerfStats (snapshot()/reset()) — per-frame busy-time and raw-delta rings plus per-phase running totals, accumulated for every frame, not just the rate-limited logged ones. This exists because the 2s console cadence gives a benchmark only 2–3 busy samples per 30s run — far too coarse to A/B a sub-millisecond cost. Same ?perfDebug=1-only lifetime.
Phase begin lives in advance() itself (not the rAF frame() wrapper), so direct drivers — the replay viewer, headless harnesses — get correct per-frame phases too. That was audit finding F21: it used to live in frame() only, and replay watching logged monotonically accumulating garbage.
scripts/run-perf-benchmark.mjs + scripts/lib/perfSampler.mjs (injected rAF interval sampler, zero game-source changes) + scripts/lib/perfConsoleParse.mjs (parser for the [perf] grammar). Reference for the full CLI is the script's own header comment; the load-bearing design points:
- Two metrics, and neither substitutes for the other. Report both.
- Busy time — the A/B metric for cost inside the frame callback. rAF pins intervals to vsync, so a cost delta smaller than the frame budget is invisible in intervals. Busy time (sum of measured phases per frame, from the stats hook) is refresh-rate-independent.
- Presented frames — dropped-frame count and percentage, for cost outside the frame callback. Busy measures the time to record canvas draw calls; rasterising that display list happens afterwards, in the GPU process, and busy cannot see it at all. A change that leaves busy flat at 6.1ms and drops a third of the frames is not hypothetical — see
perf-review-2026-08-02.md, where exactly that ran for months and was misfiled as environmental by an audit that only looked at busy. The harness derives the display's frame period from the run's own median rather than assuming 60Hz, and counts an interval at or beyond 1.5 periods as dropped.
- Calibrate before comparing:
--calibrateruns the idle cell 10 times and reports the coefficient of variation (scoped to the invocation's own cells — a--resumeinto a dir holding unrelated idle runs can no longer widen it); a delta below ~2× that spread is "no measurable difference" (≈0.1–0.2ms on the reference machine). Never claim an A/B result without stating this floor. - Flag A/B (
--flag aa|scaling|fog|floorfast|floorhalf|floorhalfauto|playerstats): interleaves baseline/flagged runs A,B,A,B (defeats thermal drift), temporarily flipping the compile-time const in-source with a guarded git-restore.fog,floorfastandfloorhalfautoare inverted (their defaults are on; the flagged variant measures turning each off). - DVFS can flip a busy A/B's sign. Busy-ms is CPU-clock-dependent under
schedutil, and the clock follows load: the u32 floor-cast read +1.3ms (a regression) in a properly interleaved A/B at 640×400 while genuinely being −0.5ms faster — the lighter arm dropped a boost bin. Interleaving does not protect against this; it is within-run, not thermal. Any busy A/B whose arms run below ~90% duty must be clock-equalized (thec2-2bgcell: two busy-loop burners in both arms) or run on a saturated cell (res-double). Pacing metrics are immune but cannot arbitrate while both arms hold refresh rate. - Scenarios (
--scenario):s1-idle(calibration workload),s2-replay(deterministic combat — the bundled default-highscore replay),s3-stress(IDKFA/IDDQD rocket+flame particle stress at the Extreme gore tier) /s3-splatter(the same macro at Absurd, the top tier — 45x spawns, an 1800-particle cap and stains that never expire;s3-stressstays pinned to Extreme so its budget history keeps comparing, and this cell tracks the current ceiling),s4-magento*(Task-241 shape: the magento2 GitHub repo's…/Pdo/Mysql.php, a 160×160 map with 280 enemies, network HAR-replayed offline — re-record withCODEENSTEIN_PERF_HAR_RECORD=1; sub-cells: idle/fire/dryfire/mouseflood/move/fire-quiet),s5-bot-demo(the balancing bot plays; needs?testHooks=1— see caveat below). The 2026-08 frame-budget audit added:c1-raycast/c2-sprites/c3-stress(worst-case balancing-corpus levels — stbstb_vorbis.cand laravelQuery/Builder.php, 522 enemies — loaded as single-file local workspaces via an OPFSshowDirectoryPickerstub,scripts/lib/opfsWorkspace.mjs, pinned?seed=; no testHooks, real clock), thel0-empty…l8-fullcumulative ablation ladder andonly-*full-minus-one cells (driven by the engine's?ablate=kill switches),res-half/res-double(internal resolution via?renderRes=WxH),view-1080/4k/6k(window-size raster probes),load-*bg(CPU burners),gpu-bg(4K-repaint neighbor window), andc2-2bg(the clock-equalized A/B cell). Level-density selection for the corpus cells:scripts/dump-level-density.mjs; per-frame draw-call census:scripts/count-draw-calls.mjs. - Substrate:
--channel chromemeasures on the installed system Chrome (the actual target) instead of Playwright's bundled Chromium; the browser version lands inmanifest.json. Measured 2026-08-16: no divergence beyond noise between the two.--no-sampler/--no-perfdebugdisable one instrument to measure its own overhead through the other (both measured ≤ the calibration noise floor). The injected sampler also carriesPerformanceObservercapture (longtask+long-animation-frame, feature-detected per run) and a per-frameusedJSHeapSizering for GC-drop detection. - Output: one JSON per run under gitignored
perf_runs/<timestamp>/,manifest.jsonfor per-cell crash resume,sceneStatesfingerprint per run (a cell whose driver silently degrades to an idle scene is visible in the data — that failure happened during the audit). - Throttle guard: any run whose median frame interval exceeds 100ms self-marks
throttled: trueand warns — an occluded/locked-screen window gets rAF-throttled to ~1Hz and would otherwise silently poison the medians (that also happened).perf:reportexcludes throttled runs from every aggregate and prints the excluded count per cell. - The harness runs its own vite on :5199 (or
CODEENSTEIN_PERF_URL); it never touches the regular dev server on 5173.
src/engine/renderCost.test.ts runs in the normal Vitest suite and asserts that no per-frame renderer issues fill(), stroke() or strokeRect() on the scene canvas. Those three are the calls measured to actually rasterise; path building (beginPath/moveTo/lineTo/arc/rect) is free until something fills it, and the automap's viewport clip() legitimately builds a rect path.
It exists because this bug class is invisible in code review: ctx.strokeRect(…) is free or costs a quarter of the frame budget depending on a lineJoin set by whatever drew before it, and drawWeapon sets exactly that. The test forces the pre-rendered fast path (it stubs an offscreen context that actually paints, so pathSprites' capability probe passes) — without that it would exercise the direct-draw fallback, which is supposed to use paths, and pass vacuously. That vacuity check is itself the first assertion in the file.
Verified to fail: injecting a raw ctx.strokeRect into drawHud turns it red with the call count, the call site and the suggested replacement.
npm run perf:report -- perf_runs/<dir> [--findings perf-findings.json] (scripts/build-perf-report.mjs) renders one or more run directories — findings only when passed explicitly via --findings; a bare invocation with no directory prints usage and exits — into the self-contained perf-report.html — interval CDFs, busy box-plots with per-run dots, phase stacks, A/B dumbbells annotated with the calibration floor, heap timelines, and the ranked findings with their outcomes.
Recorded because it undercuts the evidence for rules this codebase follows everywhere, and because the next person to measure should know before they start rather than after.
The model in perf-review-2026-08-02.md is that any
draw Skia cannot emit as an axis-aligned quad forces a coverage pass costing
~10ms/frame, fixed and all-or-nothing. Its headline datum: one ctx.fill()
per frame took 59.3 fps to 43.7. That finding is why outlineRect exists,
why pathSprites bakes a 128-step rotation atlas instead of calling
ctx.rotate, and what renderCost.test.ts guards.
Trying to answer the rotated-drawImage question that review left open
(:222), a probe was built in its own shape — a steady baseline held at the
vsync edge, then exactly one extra call per frame — and run headed on
Chrome 151 with a real GPU (NVIDIA RTX 4060 Ti via ANGLE, confirmed not
SwiftShader), interleaved across four rounds:
| arm | fps | median | frames >20ms |
|---|---|---|---|
| baseline | 58.8 | 16.70ms | 2.1% |
+ one axis-aligned drawImage |
59.6 | 16.70ms | 0.7% |
+ one rotated drawImage |
59.5 | 16.70ms | 0.8% |
+ one ctx.fill() — the control |
59.4 | 16.70ms | 1.1% |
The control is the result. fill was included precisely because the review
measured it cratering, and it did not move. So this says nothing about rotated
quads being cheap; it says the instrument could not see the effect it was built
to detect, which is a broken experiment rather than a null.
What it does not distinguish: a probe too unlike the real render path, versus a
penalty that has genuinely gone away in a newer Chrome or on different hardware.
Both are live. Do not read this as permission to start calling ctx.fill()
— but equally, do not treat the 43.7 figure as reproducible without re-taking
it. Anyone re-measuring should run the fill control first and only trust the
run if it fires.
One real-play data point, added 2026-08-23: the rotating automap was
playtested by the user on their own machine and reported as "works excellent,
no noticeable frametime impact". That is subjective and is not a frame-time
capture, so it does not settle the question above. It does bound it usefully in
one direction, though — the penalty as documented is roughly 15 fps, which
is not a subtle thing to sit behind in play. So whatever the probe's failure
means, a full coverage-pass penalty is not being paid by one rotated
drawImage per frame in the shipped automap. Whether that is because the
penalty no longer applies, or because one blit never triggered it where
thousands of rotated quads would have, is still open.
--flag edits the source const in place and restores it with git checkout --
in a finally. A hard kill skips the finally. Backgrounding the harness and
then killing it — or killing its vite — leaves the flag flipped, silently,
in a file you are about to commit.
Hit on 2026-08-23 running --flag playerstats: the job was backgrounded, killed,
and left PLAYER_STATS_ENABLED = true in the tree. Also worth knowing that
killing the harness's vite mid-matrix does not stop the matrix — it carries on
producing runs against a dead server, and those runs look plausible (they just
collapse to a few hundred frames), so they will quietly poison an A/B.
Run perf:bench in the foreground, and git diff the flag's file afterwards.
-
Never pass
?testHooks=1to a cell that should measure normal play — it switches real telemetry recording on (engine.ts,PLAYER_STATS_ENABLED ‖ testHooks). Level readiness is detected from the[perf] level:console line instead. The bot cell (s5) can't avoid it; its numbers are labeled accordingly. -
Synthetic input targets: gameplay keys must be dispatched on the canvas element (the engine's listeners live there; synthetic
KeyboardEvents don't bubble), overlay-dismiss Space on window; input within ~1s of the briefing dismissal lands beforeengine.start()attaches listeners and silently disappears;cheatQueuedholds one cheat per engine frame. Verify effects via state (sceneStates, test hooks), never assume delivery. -
Stationary players die: roaming melee enemies kill an idle player at ~t+10s — idle cells run under IDDQD, or the capture measures the Kernel Panic screen.
-
Headless is not a free choice — it is correct for busy time and blind for draw-call shape. Headless Chromium rasterises the canvas in software, on a path where the GPU-process coverage-pass penalty does not exist: every scenario reads a flat 60fps with zero dropped frames there no matter what the renderer does. The harness prints a warning when you use it, and tags the presented-frame number
[HEADLESS — cannot see raster cost]so it can never be quoted without the caveat. Anything touching paths, strokes, joins or clips must be measured headed.Demonstration, same build and same scenario, taken 30 seconds apart:
presented busy (median) headed 59.4fps, 7/713 dropped (1.0%) 6.60ms headless 60.0fps, 0/600 dropped (0.0%) 6.60ms Identical busy; the headless run cannot distinguish a healthy build from a broken one.
-
Headed mode measures the desktop too: some slow frames in headed runs are genuinely missed vsyncs with the time in
unacct(compositor/ambient load) rather than anything the game did. That is real, and it is also what a GPU-rasterisation cost looks like from inside the frame callback — the 2026-07 audit read the first as an explanation for the second and closed a real bug as environmental. Distinguishing them takes a headed A/B against a changed build, not a single capture: if suppressing a class of draw call moves the dropped-frame count, it was never ambient load.