diff --git a/static/highway.js b/static/highway.js index 56eff0f3..0ad47564 100644 --- a/static/highway.js +++ b/static/highway.js @@ -3131,6 +3131,33 @@ function createHighway() { getRenderScale() { return hwState._renderScale; }, + /** + * The render loop's own cost, for benchmarks and the perf HUD. + * + * WHY THIS EXISTS. The highway AUTO-SCALES: when the smoothed draw cost climbs past + * _DRAW_BUDGET_HI_MS it lowers the render resolution to protect the frame rate + * (#654). That is exactly right for players — and it means a performance regression + * does NOT show up as dropped frames. It shows up as a BLURRIER PICTURE at a + * perfectly healthy 60fps. Benchmark the frame rate and you will measure the + * feedback loop, not the renderer, and conclude nothing changed. + * + * So a real perf gate has to pin the scale (setMinRenderScale(1)) and watch + * `drawMs`. Everything needed for that is here; none of it was reachable before. + * + * `effectiveScale` is what actually got rendered: renderScale x autoScale. + */ + getPerf() { + return { + drawMs: hwState._drawMsEMA, // smoothed cost of _renderer.draw() + frameMs: hwState._frameMsEMA, // smoothed frame interval + renderScale: hwState._renderScale, // the user's Quality setting + autoScale: hwState._autoScale, // what the load-adaptive loop chose + effectiveScale: _effectiveRenderScale(), + drawBudgetHiMs: _DRAW_BUDGET_HI_MS, + drawBudgetLoMs: _DRAW_BUDGET_LO_MS, + }; + }, + // Floor for the load-adaptive render scale (#654). 0.25 = stock (allows // quarter-res on heavy frames), 1.0 = never auto-downscale below the // Quality (renderScale) ceiling — so it's only full resolution when diff --git a/tests/browser/highway-perf-baseline.spec.ts b/tests/browser/highway-perf-baseline.spec.ts new file mode 100644 index 00000000..feceecf3 --- /dev/null +++ b/tests/browser/highway-perf-baseline.spec.ts @@ -0,0 +1,168 @@ +import { test, expect } from '@playwright/test'; + +/** + * The R3c perf gate: highway.js's render loop must not get more expensive. + * + * ── WHY FRAME RATE IS THE WRONG THING TO MEASURE ────────────────────────────── + * + * The highway AUTO-SCALES. When the smoothed draw cost climbs past its budget + * (_DRAW_BUDGET_HI_MS = 12ms) it LOWERS THE RENDER RESOLUTION to protect the frame rate + * (#654). That is exactly right for players. It also means a real performance regression + * does not show up as dropped frames — it shows up as a BLURRIER PICTURE at a perfectly + * healthy 60fps. + * + * Benchmark fps and you measure the feedback loop, not the renderer, and cheerfully + * conclude that nothing changed while the picture quietly got worse. + * + * So this pins the scale — setRenderScale(1) + setMinRenderScale(1), which clamps + * autoScale to [1, 1] — and measures `drawMs`, the renderer's own cost, straight from + * highway.getPerf(). With the adaptive loop held still, drawMs is the signal. + * + * ── WHAT IT ASSERTS, AND THE TRAP I FELL INTO FIRST ────────────────────────── + * + * My first cut asserted "the auto-scaler was not forced to intervene" — i.e. effectiveScale + * still == 1. That gate is VACUOUS, and the bite test proved it: I injected a 10x + * regression (drawMs 2.4 -> 22.4ms, nearly double the 12ms budget) and the test PASSED. + * + * Of course it did. setMinRenderScale(1) sets the auto-scaler's FLOOR to 1, so + * effectiveScale CANNOT drop below 1 — the very pinning that stops the scaler from hiding a + * regression also stops it from ever reporting one. A guard that cannot fail. + * + * So with the scale pinned, drawMs IS the signal, and the threshold is the app's own: + * _DRAW_BUDGET_HI_MS (12ms) is the cost at which the highway itself decides it is too + * expensive and starts dropping resolution in production. Exceeding it is not an arbitrary + * line in a benchmark — it is the renderer failing its own budget. + * + * That is a real gate and not a flaky one: the current cost is ~2.4ms, so there is ~5x + * headroom before it trips, which is far more than headless-CI variance and far less than + * any regression worth shipping. + */ +test('highway draw cost stays within its own render budget', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('.screen.active', { timeout: 10000 }); + + const perf = await page.evaluate(async () => { + const w = window as any; + const hw = w.highway; + if (!hw || typeof hw.getPerf !== 'function') { + return { error: 'highway.getPerf() missing — the perf gate is blind' }; + } + + // Pin the adaptive loop so it cannot mask a regression by dropping resolution. + hw.setRenderScale(1); + hw.setMinRenderScale(1); + + // Load a real chart and get the transport ACTUALLY RUNNING. + // + // Codex [P2] on the first cut of this, and it was right: headless Chromium may block + // autoplay, in which case playSong() only LOADS the chart. The audio clock never + // advances, the draw loop treats the session as paused, and _drawMsEMA keeps whatever + // stale value it had at startup. The gate would then sample an IDLE renderer and + // cheerfully report a healthy 2ms — while measuring nothing at all, on exactly the path + // it exists to protect. + const d = await (await fetch('/api/library?limit=1')).json(); + const f = d.songs && d.songs[0] && (d.songs[0].filename || d.songs[0].id); + if (!f) return { error: 'no song in the library — the perf gate has nothing to render' }; + + const audio = document.getElementById('audio') as HTMLAudioElement | null; + if (audio) audio.muted = true; // so autoplay policy cannot refuse us + // ENCODE. playSong() decodes its argument before interpolating it into the /ws/highway + // path, so every real caller hands it encodeURIComponent(filename) (app.js:2879, 4137). + // A raw filename containing #, ?, % or / builds an invalid WebSocket URL and the song + // never loads — on which libraries this gate would silently measure an idle renderer + // rather than fail. Codex [P2], and correct. + await w.playSong(encodeURIComponent(f)); + + // WAIT for playback to start; do NOT force it on a fixed timer. + // + // playSong() autoplays, but it takes ~3-4s to get there — it is fetching and decoding + // stems. An earlier version of this test called togglePlay() after a flat 2s "if not + // playing yet", which fired BEFORE autoplay, started playback, and then had the app's + // own autoplay toggle it straight back to PAUSED. The renderer then idled through the + // whole measurement and the gate happily reported 2ms of nothing. + const playDeadline = Date.now() + 12000; + while (!w.feedBack.isPlaying && Date.now() < playDeadline) { + await new Promise((r) => setTimeout(r, 250)); + } + // Only intervene if it truly never started (a stricter autoplay policy than we expect). + if (!w.feedBack.isPlaying) await w.togglePlay(); + + // Wait for the CHART CLOCK to actually move. That is the proof the render loop is doing + // real per-frame work, not sitting paused. + const t0 = hw.getTime(); + const deadline = Date.now() + 8000; + while (hw.getTime() - t0 < 0.5 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 100)); + } + const advanced = hw.getTime() - t0; + + // Let the EMAs settle under load (they are 0.9/0.1, so they need a few dozen frames). + await new Promise((r) => setTimeout(r, 2500)); + + // Sample — and measure the clock ACROSS the sampling window, not just before it. + // "It advanced at some point earlier" is not good enough: if playback stopped before we + // started sampling (short song, ended track, autoplay revoked), the EMAs decay toward + // idle and we would be reading the cost of drawing nothing. + const sampleStart = hw.getTime(); + const samples: number[] = []; + for (let i = 0; i < 30; i++) { + await new Promise((r) => requestAnimationFrame(() => r(null))); + samples.push(hw.getPerf().drawMs); + } + const advancedDuringSampling = hw.getTime() - sampleStart; + + return { + ...hw.getPerf(), + samples, + advanced, + advancedDuringSampling, + isPlaying: !!w.feedBack.isPlaying, + }; + }); + + expect(perf.error, String(perf.error)).toBeUndefined(); + + console.log( + `[highway perf] drawMs=${(perf.drawMs ?? 0).toFixed(2)} frameMs=${(perf.frameMs ?? 0).toFixed(2)} ` + + `renderScale=${perf.renderScale} autoScale=${perf.autoScale} effectiveScale=${perf.effectiveScale} ` + + `budget=${perf.drawBudgetLoMs}..${perf.drawBudgetHiMs}ms ` + + `playing=${perf.isPlaying} advancedBefore=${(perf.advanced ?? 0).toFixed(2)}s ` + + `advancedDuringSampling=${(perf.advancedDuringSampling ?? 0).toFixed(3)}s`, + ); + + // 0. THE GATE MUST NOT BE MEASURING AN IDLE RENDERER. If the transport never started, the + // draw loop is paused, _drawMsEMA is a stale startup value, and every assertion below + // passes while testing nothing. Assert the chart clock actually MOVED. + expect( + perf.advanced, + 'the chart clock never advanced — playback did not start, so drawMs is a stale idle ' + + 'value and this gate is measuring nothing', + ).toBeGreaterThan(0.5); + + // …and it must STILL have been advancing while we sampled. "It moved at some point + // earlier" is not good enough: if playback stopped before the sampling window, the EMAs + // decay toward idle and we would be measuring the cost of drawing nothing. + expect( + perf.advancedDuringSampling, + 'the chart clock was not advancing DURING the sampling window — playback stopped, so ' + + 'these drawMs samples are the cost of an idle renderer, not a rendering one', + ).toBeGreaterThan(0); + + // 1. the renderer actually ran and reports a sane cost + expect(Number.isFinite(perf.drawMs)).toBe(true); + expect(perf.drawMs).toBeGreaterThan(0); + + // 2. THE REGRESSION SIGNAL. With the scale pinned, drawMs is the renderer's true cost. + // _DRAW_BUDGET_HI_MS is the app's OWN definition of "too expensive" — the cost at + // which it starts sacrificing resolution for players in production. Blow through it + // and the renderer has failed its own budget. + // + // Do NOT be tempted to assert on effectiveScale instead: pinning the scale makes that + // number a constant, so it can never report anything. See the note above. + expect( + perf.drawMs, + `highway draw cost ${perf.drawMs.toFixed(2)}ms exceeds its own budget of ` + + `${perf.drawBudgetHiMs}ms — in production this is the point where the highway starts ` + + `dropping render resolution to keep up`, + ).toBeLessThan(perf.drawBudgetHiMs); +});