#!/usr/bin/env node /** Repeatable anonymous WebGL performance gate over a built Tera bundle. */ import { chromium } from "playwright"; import { createServer } from "node:http"; import { access, readdir, readFile, writeFile } from "node:fs/promises"; import { extname, join, normalize, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = fileURLToPath(new URL("..", import.meta.url)); const DIST = join(ROOT, "dist"); const DEFAULT_BUDGETS = join(ROOT, "scripts", "performance-budgets.json"); const PRIVATE_PATH = /^\/api\/v1\/(?:media|realtime|presence|offices)(?:\/|$)|^\/assets\/(?:remoteMedia|presenceIndicator|scenePeers)-/; const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp", ".webmanifest": "application/manifest+json" }; const VIEWPORTS = { desktop: { width: 1440, height: 900, deviceScaleFactor: 1 }, mobile: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, }; /* * EVERY SCENE NAMES THE BOARD IT MEANS, AND CHECKS IT GOT IT. * * `?city=` falls back to the first board rather than failing on an unknown id — * deliberately, so a deep link to a city this build does not have shows a city * instead of a black screen — and that fallback is silent. A `bay-area` cell * that quietly measured California would pass its 2.6M-triangle cap by a factor * of six and would keep passing through any regression a phone would choke on: * a budget that cannot fail. So every scene names a `signature` — one chapter * `data-view` that exists on that board and on no other — and does not become * ready until it is on screen. * * A SIGNATURE CHAPTER RATHER THAN THE BOARD TAB, deliberately. The obvious check * is the `data-board` of the pressed tab, and it was written that way first; it * broke within the hour, because the tab strip is chrome and chrome is exactly * what the continuity work is allowed to change. A chapter id is pack data, it * is what `scripts/fixtures/chapter-identity.json` pins, and it is what every * capture aim resolves to. `hayes-valley` and `dtla` are unique across the three * packs; `all` is not, which is why neither metro is identified by its first * chapter. * * `california-drive` used to reach its chapter with * `document.querySelectorAll("#chapters .chapter")[1].click()`. That is an * unguarded index into pack data, and it is the exact pattern that once shipped * a share card of a chase camera on a freeway under the headline "Cities from * above" — see the note at `scripts/brand-assets/capture.mjs:70`. It now aims at * `data-view="la-sf-us-101"` and asserts the printed label, so a reordered pack * stops the run instead of quietly measuring a different scene under the old * scene's caps. */ const SCENES = { california: { host: "tera.lumbridgecorp.com", // Explicit, and it was implicit until the app's default board moved off the // state tier. `signature` would have caught it — a board with no // `california-overview` chapter never becomes ready — but a harness that // depends on an app default is a harness that reports someone else's change // as its own flake. query: "?city=california&handover=0", signature: "california-overview", ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='california-overview']") !== null, }, "california-drive": { host: "tera.lumbridgecorp.com", query: "?city=california&handover=0", signature: "california-overview", ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='california-overview']") !== null && document.querySelector("#chapters .chapter[data-view='la-sf-us-101']") !== null, activate: () => { const button = document.querySelector("#chapters .chapter[data-view='la-sf-us-101']"); if (button === null) throw new Error("no chapter with data-view='la-sf-us-101' on this board"); const spans = [...button.querySelectorAll("span")]; const label = (spans[spans.length - 1]?.textContent ?? "").trim(); if (label !== "101") throw new Error(`chapter la-sf-us-101 prints "${label}", not "101" — the pack was re-labelled`); button.click(); }, active: () => document.querySelector("[data-control-mode='drive']")?.getAttribute("aria-pressed") === "true" && document.getElementById("play-hud")?.hidden === false, }, office: { host: "office.lumbridgecorp.com", query: "?handover=0", ready: () => document.getElementById("boot")?.hidden === true && document.getElementById("enter")?.textContent?.includes("Back to the city") === true }, /* * The two metro boards, added after they became the boards with the most on * them and were still the boards nothing measured. * * For a long time the matrix was california, california-drive and office. That * was defensible while the metro boards were terrain and buildings, and stopped * being defensible the moment SFO, LAX, the Golden Gate, the Bay Bridge and a * surfaced freeway all landed on them — every one of those is city-frame * geometry, and every one of them arrived in a frame with no budget watching * it. A cap you do not measure is a cap you do not have. * * Their own numbers rather than California's: `sf` carries the densest built * ground in the product and `socal` the widest basin, and holding either to a * board tuned for a whole state would be arbitrary in both directions. These * are set from the first measured run with headroom deliberately left, in the * same spirit as the others. */ "bay-area": { host: "tera.lumbridgecorp.com", query: "?city=sf&handover=0", signature: "hayes-valley", ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='hayes-valley']") !== null, }, socal: { host: "tera.lumbridgecorp.com", query: "?city=socal&handover=0", signature: "dtla", ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='dtla']") !== null, }, }; const args = process.argv.slice(2); function option(name, fallback) { const at = args.indexOf(`--${name}`); return at < 0 ? fallback : args[at + 1]; } function positive(name, fallback) { const value = Number(option(name, fallback)); if (!Number.isFinite(value) || value <= 0) throw new Error(`--${name} must be a positive number`); return value; } /* * WHY THE TWO METRO BOARDS CARRY CAPS THREE TIMES CALIFORNIA'S. * * Measured on the first run that included them: bay-area 2,265,056 triangles and * socal 1,417,648, against california's 391,169. That is not a regression and it * is not slack — it is what those boards are. California is one state at a * standoff where a building is a speck; the Bay Area is the densest built ground * in the product with every lot, the freeway network, both bridges and SFO in * frame at once, and the Southland is the widest basin with LAX and five more * fields on it. Holding either to a cap tuned for a whole state would be * arbitrary in both directions. * * Both render at 60 fps (p95 16.7-16.8 ms) on the box that measured them, which * has a Radeon RX 6700 XT. That is the honest limit of what these numbers prove. * * WHY THE MOBILE CELLS NOW CARRY THEIR OWN GEOMETRY CAPS. * * They used to carry desktop's, and a cap that cannot move is a cap that catches * nothing. Until 2026-08-22 the handheld path reduced the pixel ratio and the * shadow-map edge and reduced *nothing about the scene*: the mobile cell drew * 2,263,784 triangles against desktop's 2,265,056 — a phone drew every triangle a * desktop did — so the mobile row was a frame-time gate wearing a geometry gate's * clothes, and it would have stayed green through any geometry regression a phone * would choke on. `blocksCastShadow()` in `src/engine/blocks.ts` now takes the * anonymous city out of the shadow caster set on a handheld, and these caps are * set from what that reduced path actually produces, with the same headroom the * desktop rows were given. Every one of them is a tightening — the mobile rows * used to carry desktop's numbers, so all ten moved down. Measured 2026-08-22: * * | cell | triangles before | after | cap | draws | cap | * |---|---|---|---|---|---| * | bay-area/mobile | 2,263,784 | 1,266,176 | 1,450,000 | 201 | 240 | * | socal/mobile | 1,410,800 | 765,596 | 900,000 | 137 | 170 | * | california/mobile | 389,843 | 389,843 | 460,000 | 368 | 420 | * | california-drive/mobile | 371,599 | 371,599 | 440,000 | 232 | 280 | * | office/mobile | 143,780 | 143,780 | 200,000 | 431 | 520 | * * California does not move because its 806 m lots were never in the caster set * (see `NEIGHBOURHOOD_LOT_METRES`), and the office does not move because a desk * shadow at 1 unit = 1 m is the whole read of depth. * * ONE CORRECTION WORTH LEAVING HERE, because the plan for this work carried it: * the shadow pass on bay-area is ~70 draw calls, and those 70 are NOT the * buildings. The anonymous city is one `InstancedMesh`, so taking it out of the * caster set is worth 997,608 triangles and exactly ONE draw call. The other 69 * are the landmarks, the bridges, the surfaced freeway and the airports — every * one of them a separate mesh, and every one of them a silhouette somebody put * there on purpose. Dropping them too would get the cell under 150 draws and * would be a visual regression bought with a number. Triangles were the lever; * draw calls were never going to be. * * AND THE MOBILE CELL'S PIXEL COUNT IS 0.74 MP, NOT 1.32. * * This file used to claim the mobile cell rendered "1.32 MP against desktop's * 1.30", which was arithmetic on the CSS size times `deviceScaleFactor: 2` and * was never what the renderer did. Measured in-page — `renderer.getPixelRatio()` * is 1.5 and the canvas backing store is 585x1266 — the mobile cell renders * **0.74 MP, 57% of desktop's 1.30**, because `deviceProfile()` caps the handheld * pixel ratio at 1.5. Every cell now records its own `measuredPixels`, so nobody * has to take a comment's word for it again. * * ITEM 8 IS CLOSED: THE BAY AREA ALLOWANCE IS GONE AND THE SUSPECT WAS WRONG. * * `bay-area.desktop.p95FrameIntervalMs` carried 33.4 as a recorded defect, with * the desktop shadow map named as the first thing to rule in or out. It is ruled * OUT, by measurement rather than by argument. On the same build in one session, * timed with `EXT_disjoint_timer_query_webgl2`: a 4096-texel map renders the * bay-area frame in 1.31 ms, 2048 in 1.22, 1024 in 1.21 and 256 in 1.26 — the * whole spread is inside the run-to-run noise of a 1.2 ms frame, because the * shadow pass costs geometry submission and not rasterisation. * * What did correlate is the card. Sampling `/sys/class/drm/card1/device/pp_dpm_sclk` * and `pp_dpm_mclk` every 250 ms through a session that reproduced p95 33.3, the * GPU never left its lowest DPM states — 500 MHz core out of 2725, and 96 or * 456 MHz memory out of 1000 — for every one of ~250 samples. A board that needs * 1.2 ms at boost needs an order of magnitude more at 18% of core clock, which * puts it near the 16.7 ms deadline and makes the miss a coin flip on when the * DPM state machine steps. That is why the drop rate is stochastic, why it scales * with board weight (bay-area > socal > california = never), and why two * consecutive runs over a byte-identical `dist` gave socal desktop 33.4 then 16.7 * with triangle and draw counts identical to the digit. * * So two things changed here rather than the cap being left raised: * * - Every cell is measured up to `--repeats` times and the first passing run is * the one recorded, with all attempts kept in `attempts[]`. A single red p95 * on this class of box is noise until it reproduces; a geometry regression * reproduces on the first attempt and every attempt after it. * - The report records the GPU's DPM state next to the renderer string, so the * next person reading a red frame-time cell can see whether the card was * awake. The confirming experiment, if you have root: * `echo high > /sys/class/drm/card1/device/power_dpm_force_performance_level`. * * Frame times on a shared box are not trustworthy from a single run. Triangle and * draw-call counts are: they were identical to the digit across every run of a * given build in every experiment above. Believe the geometry columns; re-run * before believing a frame-time column. */ function sceneBudget(value, label) { if (!value || typeof value !== "object") throw new Error(`missing budget for ${label}`); for (const key of ["p95FrameIntervalMs", "maxDrawCalls", "maxTriangles"]) { if (typeof value[key] !== "number" || !Number.isFinite(value[key]) || value[key] <= 0) { throw new Error(`${label}.${key} must be a positive number`); } } return value; } const outputPath = resolve(option("output", "/tmp/tera-performance-budget.json")); const budgetPath = resolve(option("budgets", DEFAULT_BUDGETS)); const warmupMs = positive("warmup-ms", 3_000); const sampleMs = positive("sample-ms", 8_000); const readyTimeoutMs = positive("ready-timeout-ms", 180_000); const softwareOnly = args.includes("--software"); /* * How many times a cell may be measured before its result is believed. * * See the DPM paragraph above. The first attempt that passes is the recorded * one; if none passes, the last is recorded and every attempt is kept in * `attempts[]` so a reader can see whether a red cell was one bad run or a * property of the build. Three because two consecutive runs over a * byte-identical `dist` have been observed to disagree by a factor of two on * frame time while agreeing to the digit on triangles and draw calls. */ const repeats = Math.max(1, Math.round(positive("repeats", 3))); // Chrome exposes rAF timestamps at 0.1 ms precision, so an ideal 60 Hz cadence // can quantize to 16.8 ms. This tolerance is measurement resolution, not budget // headroom; reported values and declared budgets remain unchanged. const FRAME_COMPARISON_EPSILON_MS = 0.1; function percentile(values, fraction) { if (values.length === 0) return null; const ordered = [...values].sort((a, b) => a - b); return ordered[Math.max(0, Math.ceil(ordered.length * fraction) - 1)]; } const rounded = (value) => value === null ? null : Math.round(value * 1_000) / 1_000; async function serve() { const requests = []; const server = createServer(async (req, res) => { const url = new URL(req.url ?? "/", "http://local.invalid"); requests.push({ method: req.method ?? "GET", path: url.pathname }); if (url.pathname === "/api/v1/health") { res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify({ auth: { mode: "jwt", entryUrl: "/login.html" }, sources: { weather: "none", flights: "none", satellites: "none", markers: "none" } })); return; } if (url.pathname === "/api/v1/session") { res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify({ authenticated: false, subject: null, passwordLogin: false, admin: false })); return; } if (url.pathname.startsWith("/api/v1/")) { res.writeHead(404, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify({ error: "not_found" })); return; } const requested = normalize(decodeURIComponent(url.pathname)).replace(/^(?:\.\.[/\\])+/, ""); for (const relative of [requested === "/" ? "/index.html" : requested, "/index.html"]) { const target = resolve(DIST, `.${relative}`); if (!target.startsWith(`${resolve(DIST)}/`)) continue; try { const body = await readFile(target); res.writeHead(200, { "content-type": MIME[extname(target)] ?? "application/octet-stream" }); res.end(body); return; } catch { /* SPA fallback */ } } res.writeHead(404).end("not found"); }); await new Promise((ok, fail) => { server.once("error", fail); server.listen(0, "127.0.0.1", ok); }); const address = server.address(); if (!address || typeof address === "string") throw new Error("performance server did not expose a TCP port"); return { server, requests, port: address.port }; } /* * What the GPU's power management was doing while this ran. * * Recorded next to the renderer string because a red frame-time cell on this * class of box is very often a downclocked card rather than a heavy scene: a * Radeon sitting at 500 MHz of a possible 2725 needs an order of magnitude more * time for a frame it renders in 1.2 ms at boost, which is enough to put a * healthy board near the 16.7 ms deadline. amdgpu marks the active state with a * trailing `*` in `pp_dpm_sclk` / `pp_dpm_mclk`. Best-effort and Linux-only — * every read is allowed to fail, and a machine without these files simply * records `null`, because a missing instrument must never fail a build. */ async function gpuPowerState() { try { const cards = (await readdir("/sys/class/drm")).filter((name) => /^card\d+$/.test(name)).sort(); const states = []; for (const card of cards) { const base = `/sys/class/drm/${card}/device`; const read = async (leaf) => { try { return (await readFile(`${base}/${leaf}`, "utf8")).trim(); } catch { return null; } }; const active = (table) => { if (!table) return null; const line = table.split("\n").find((row) => row.trimEnd().endsWith("*")); return line ? line.replace(/^\s*\d+:\s*/, "").replace(/\s*\*$/, "").trim() : null; }; const ceiling = (table) => { if (!table) return null; const rows = table.split("\n").filter(Boolean); const last = rows[rows.length - 1]; return last ? last.replace(/^\s*\d+:\s*/, "").replace(/\s*\*$/, "").trim() : null; }; const sclk = await read("pp_dpm_sclk"); const mclk = await read("pp_dpm_mclk"); if (sclk === null && mclk === null) continue; states.push({ card, forcePerformanceLevel: await read("power_dpm_force_performance_level"), coreClock: active(sclk), coreClockCeiling: ceiling(sclk), memoryClock: active(mclk), memoryClockCeiling: ceiling(mclk), }); } return states.length ? states : null; } catch { return null; } } const COMMON_ARGS = ["--no-sandbox", "--disable-dev-shm-usage"]; async function rendererOf(browser) { const page = await browser.newPage(); try { await page.goto("about:blank"); return await page.evaluate(() => { const gl = document.createElement("canvas").getContext("webgl2"); const extension = gl?.getExtension("WEBGL_debug_renderer_info"); return extension ? String(gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)) : null; }); } finally { await page.close(); } } async function launchBrowser(port) { const resolver = `--host-resolver-rules=MAP tera.lumbridgecorp.com 127.0.0.1, MAP office.lumbridgecorp.com 127.0.0.1`; const attempts = softwareOnly ? [["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader"]]] : [["vulkan", ["--use-gl=angle", "--use-angle=vulkan"]], ["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader"]]]; let last; for (const [backend, flags] of attempts) { try { const browser = await chromium.launch({ channel: "chrome", args: [...COMMON_ARGS, resolver, ...flags] }); const renderer = await rendererOf(browser); const softwareRenderer = renderer !== null && /SwiftShader|llvmpipe/i.test(renderer); if (renderer && (backend === "swiftshader" || !softwareRenderer)) return { browser, backend, renderer, port }; await browser.close(); } catch (error) { last = error; } } for (const executablePath of ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"]) { try { await access(executablePath); const browser = await chromium.launch({ executablePath, args: [...COMMON_ARGS, resolver, "--use-gl=angle", "--use-angle=swiftshader"] }); return { browser, backend: "system-chrome-swiftshader", renderer: await rendererOf(browser), port }; } catch (error) { last = error; } } throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`); } function instrumentation() { // Custom production hostnames mapped to loopback are not secure contexts over // HTTP, so Chrome withholds randomUUID even though getRandomValues remains. // Production is HTTPS; this shim only restores that API in the local harness. if (typeof crypto.randomUUID !== "function") { crypto.randomUUID = () => { const bytes = crypto.getRandomValues(new Uint8Array(16)); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join(""); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; }; } const state = { frames: [], longTasks: [], drawCalls: [], triangles: [], currentCalls: 0, currentTriangles: 0, last: 0, longTaskSupported: false }; Object.defineProperty(globalThis, "__teraPerformanceBudget", { value: state }); const triangleCount = (mode, count) => mode === 4 ? count / 3 : (mode === 5 || mode === 6) ? Math.max(0, count - 2) : 0; const patch = (prototype, method, countAt, instancesAt = null) => { if (!prototype || typeof prototype[method] !== "function") return; const original = prototype[method]; if (original.__teraPerformancePatched) return; const wrapped = function (...values) { state.currentCalls += 1; const instances = instancesAt === null ? 1 : Number(values[instancesAt]) || 0; state.currentTriangles += triangleCount(Number(values[0]), Number(values[countAt]) || 0) * instances; return original.apply(this, values); }; Object.defineProperty(wrapped, "__teraPerformancePatched", { value: true }); prototype[method] = wrapped; }; for (const prototype of [globalThis.WebGLRenderingContext?.prototype, globalThis.WebGL2RenderingContext?.prototype]) { patch(prototype, "drawArrays", 2); patch(prototype, "drawElements", 1); patch(prototype, "drawArraysInstanced", 2, 3); patch(prototype, "drawElementsInstanced", 1, 4); } try { new PerformanceObserver((list) => state.longTasks.push(...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })))).observe({ type: "longtask", buffered: true }); state.longTaskSupported = true; } catch { /* Long Tasks API is optional. */ } requestAnimationFrame(function sample(now) { if (state.last > 0) { state.frames.push(now - state.last); state.drawCalls.push(state.currentCalls); state.triangles.push(state.currentTriangles); } state.last = now; state.currentCalls = 0; state.currentTriangles = 0; requestAnimationFrame(sample); }); } async function measure(browser, port, sceneName, viewportName, budget, requestLog) { const viewport = VIEWPORTS[viewportName]; /* * `reducedMotion: "reduce"` because the harness measures a *settled* frame. * * The opening arrival — `arrive()` in `scene.ts`, and `flyTo` before it — * collapses to a cut under the media query, exactly as a viewer who has asked * their operating system for less motion gets. Without it the shot's length * has to be chosen against `warmup-ms`, since a move still running inside the * sample window is a moving frustum and everything downstream of it is * measuring a fly-in rather than a board. With it the two are decoupled: the * arrival can be as long as it wants to be and this file still samples a * stationary camera. Requested by whoever owns the hero landing; if you raise * `warmup-ms`, tell them, because the shot can then be lengthened. */ const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: viewport.deviceScaleFactor, isMobile: viewport.isMobile, hasTouch: viewport.hasTouch, reducedMotion: "reduce" }); const page = await context.newPage(); const consoleErrors = []; page.on("pageerror", (error) => consoleErrors.push(String(error))); page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); }); const before = requestLog.length; await page.addInitScript(instrumentation); const scene = SCENES[sceneName]; const url = `http://${scene.host}:${port}/${scene.query ?? ""}`; try { await page.goto(url, { waitUntil: "networkidle", timeout: readyTimeoutMs }); try { await page.waitForFunction(scene.ready, null, { timeout: readyTimeoutMs }); } catch (error) { const state = await page.evaluate(() => ({ title: document.title, bootHidden: document.getElementById("boot")?.hidden ?? null, bootText: document.getElementById("boot")?.textContent?.trim().slice(0, 240) ?? null, chapters: document.querySelectorAll("#chapters .chapter").length, // Which chapters actually answered. A readiness timeout on a metro cell // is now most likely to mean `?city=` fell back to California, and // reading the ids back makes that a one-word diagnosis rather than a // twenty-minute one. `activeBoard` is best-effort: the board tab strip // is chrome and may not exist. views: [...document.querySelectorAll("#chapters .chapter")].map((node) => node.getAttribute("data-view")), activeBoard: document.querySelector("[data-board][aria-pressed='true']")?.getAttribute("data-board") ?? null, enter: document.getElementById("enter")?.textContent?.trim() ?? null, })).catch(() => null); throw new Error(`scene readiness timed out: ${JSON.stringify({ state, consoleErrors, requests: requestLog.slice(before) })}`, { cause: error }); } if (scene.activate) { await page.evaluate(scene.activate); await page.waitForFunction(scene.active, null, { timeout: readyTimeoutMs }); } await page.waitForTimeout(warmupMs); await page.evaluate(() => { const state = globalThis.__teraPerformanceBudget; state.frames.length = state.drawCalls.length = state.triangles.length = state.longTasks.length = 0; state.last = performance.now(); state.currentCalls = 0; state.currentTriangles = 0; }); await page.waitForTimeout(sampleMs); const raw = await page.evaluate(() => { const state = globalThis.__teraPerformanceBudget; const canvas = document.querySelector("canvas"); const gl = canvas?.getContext("webgl2") ?? canvas?.getContext("webgl"); const extension = gl?.getExtension("WEBGL_debug_renderer_info"); return { frames: [...state.frames], drawCalls: [...state.drawCalls], triangles: [...state.triangles], longTasks: [...state.longTasks], longTaskSupported: state.longTaskSupported, renderer: extension ? String(gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)) : null, // The canvas backing store, which is the only honest pixel count: the // renderer caps its own pixel ratio (1.5 on a handheld) and does not // care what `deviceScaleFactor` the harness asked for. See the header. drawingBuffer: canvas ? { width: canvas.width, height: canvas.height, cssWidth: canvas.clientWidth, cssHeight: canvas.clientHeight } : null, }; }); const relevantRequests = requestLog.slice(before); const privateRequests = relevantRequests.filter((request) => PRIVATE_PATH.test(request.path)); const metrics = { frameSamples: raw.frames.length, p50FrameIntervalMs: rounded(percentile(raw.frames, 0.5)), p95FrameIntervalMs: rounded(percentile(raw.frames, 0.95)), p99FrameIntervalMs: rounded(percentile(raw.frames, 0.99)), maxFrameIntervalMs: rounded(raw.frames.length ? Math.max(...raw.frames) : null), p95DrawCalls: rounded(percentile(raw.drawCalls, 0.95)), maxDrawCalls: raw.drawCalls.length ? Math.max(...raw.drawCalls) : null, p95Triangles: rounded(percentile(raw.triangles, 0.95)), maxTriangles: raw.triangles.length ? Math.max(...raw.triangles) : null, longTasksSupported: raw.longTaskSupported, longTaskCount: raw.longTasks.length, longTaskTotalMs: raw.longTasks.reduce((sum, task) => sum + task.duration, 0), }; const checks = { p95FrameIntervalMs: metrics.p95FrameIntervalMs !== null && Math.round(metrics.p95FrameIntervalMs * 10) <= Math.round((budget.p95FrameIntervalMs + FRAME_COMPARISON_EPSILON_MS) * 10), maxDrawCalls: metrics.maxDrawCalls !== null && metrics.maxDrawCalls <= budget.maxDrawCalls, maxTriangles: metrics.maxTriangles !== null && metrics.maxTriangles <= budget.maxTriangles, anonymousPrivateRequests: privateRequests.length === 0, consoleErrors: consoleErrors.length === 0, enoughFrameSamples: metrics.frameSamples >= Math.max(30, Math.floor(sampleMs / 100)), }; const measuredPixels = raw.drawingBuffer ? { ...raw.drawingBuffer, megapixels: Math.round((raw.drawingBuffer.width * raw.drawingBuffer.height) / 10_000) / 100, } : null; return { scene: sceneName, viewport: viewportName, url, viewportPixels: viewport, measuredPixels, renderer: raw.renderer, budget, metrics, checks, passed: Object.values(checks).every(Boolean), privateRequests, consoleErrors }; } finally { await context.close(); } } let server; let browser; try { await access(join(DIST, "index.html")); const budgets = JSON.parse(await readFile(budgetPath, "utf8")); const hosted = await serve(); server = hosted.server; const launched = await launchBrowser(hosted.port); browser = launched.browser; const results = []; const gpuBefore = await gpuPowerState(); for (const scene of Object.keys(SCENES)) for (const viewport of Object.keys(VIEWPORTS)) { const budget = sceneBudget(budgets?.scenes?.[scene]?.[viewport], `${scene}.${viewport}`); /* * Repeat until stable, and record why. * * The first attempt that passes is the recorded result. A cell that never * passes records its LAST attempt — not its best — because picking the * kindest of three runs is how a gate stops meaning anything, and the point * of the retry is to distinguish "this box stuttered once" from "this build * is slow", not to shop for a green. * * `attempts[]` keeps every run's headline numbers either way. In practice a * geometry regression is identical across all three attempts (triangles and * draw calls have never disagreed between runs of one build) and a * downclocked card is the only thing that moves, which is exactly the * signal this is here to separate. */ const attempts = []; let accepted = null; for (let attempt = 1; attempt <= repeats; attempt++) { process.stderr.write(`performance-budget: ${scene}/${viewport}${attempt > 1 ? ` (attempt ${attempt}/${repeats})` : ""}\n`); const run = await measure(browser, hosted.port, scene, viewport, budget, hosted.requests); attempts.push({ attempt, passed: run.passed, failed: Object.entries(run.checks).filter(([, ok]) => !ok).map(([name]) => name), frameSamples: run.metrics.frameSamples, p50FrameIntervalMs: run.metrics.p50FrameIntervalMs, p95FrameIntervalMs: run.metrics.p95FrameIntervalMs, maxTriangles: run.metrics.maxTriangles, maxDrawCalls: run.metrics.maxDrawCalls, }); accepted = run; if (run.passed) break; } results.push({ ...accepted, attempts, attemptsRun: attempts.length, repeatsAllowed: repeats }); } const report = { schemaVersion: 1, generatedAt: new Date().toISOString(), browserPlugin: "not available; Playwright system Chrome fallback used", browser: { backend: launched.backend, renderer: launched.renderer }, warmupMs, sampleMs, repeats, /* * The card's own power state, before and after the matrix. A frame-time cell * measured against a GPU pinned at its lowest DPM step is measuring the * power manager; see the header. `power_dpm_force_performance_level: "auto"` * with a core clock far below its ceiling is the signature. */ gpu: { before: gpuBefore, after: await gpuPowerState() }, frameComparisonEpsilonMs: FRAME_COMPARISON_EPSILON_MS, budgets: budgetPath, passed: results.every((result) => result.passed), results, }; await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`); process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); process.stderr.write(`performance-budget: ${report.passed ? "PASS" : "FAIL"} — ${outputPath}\n`); if (!report.passed) process.exitCode = 1; } catch (error) { console.error(`performance-budget: ERROR — ${error instanceof Error ? error.stack : String(error)}`); process.exitCode = 2; } finally { await browser?.close().catch(() => undefined); if (server) await new Promise((resolveClose) => server.close(resolveClose)); }