// scripts/bench-headroom.ts
//
// Measures sustained frame cadence (via requestAnimationFrame) for a
// preset at a given agent count. Use this to verify the engine still
// hits vsync after preset / param changes — or to find the agent ceiling
// on a given machine.
//
// Setup:
// 1. npm run dev (in a separate terminal — dev server must be up)
// 2. npx tsx scripts/bench-headroom.ts
//
// Optional env:
// PHYSARUM_URL=http://localhost:5173 (target URL)
// PHYSARUM_PRESET=aurora-veil (which built-in preset; default = aurora-veil)
// PHYSARUM_AGENTS=500000 (override agentCount via ?stress_agents=)
// PHYSARUM_SETTLE_MS=4000 (warmup before sampling)
// PHYSARUM_SAMPLE_MS=6000 (sample window)
//
// Note: the engine clamps agentCount to the perf tier's budget at init
// time (`budgetForTier` in src/engine/adapter.ts). On desktop tier the
// cap is currently 500_000 (matches the schema cap); mid is 100_000,
// mobile is 50_000, minimum is 10_000. Setting PHYSARUM_AGENTS higher
// than the tier cap measures the cap, not the env value.
import { chromium } from '@playwright/test';
const APP_URL = process.env.PHYSARUM_URL ?? 'http://localhost:5173';
const PRESET = process.env.PHYSARUM_PRESET ?? 'aurora-veil';
const AGENTS = Number.parseInt(process.env.PHYSARUM_AGENTS ?? '200000', 10);
const SETTLE_MS = Number.parseInt(process.env.PHYSARUM_SETTLE_MS ?? '4000', 10);
const SAMPLE_MS = Number.parseInt(process.env.PHYSARUM_SAMPLE_MS ?? '6000', 10);
interface FrameSample {
frames: number;
ms: number;
fps: number;
min: number;
max: number;
p50: number;
p95: number;
}
async function main(): Promise<void> {
const url = `${APP_URL}/?preset=${encodeURIComponent(PRESET)}&stress_agents=${AGENTS}`;
console.log(`bench-headroom: ${url}`);
console.log(` settle=${SETTLE_MS}ms sample=${SAMPLE_MS}ms`);
// Headed mode required on macOS — same reason as bake-thumbnails: the
// headless Chrome renderer doesn't expose a working WebGPU device.
const browser = await chromium.launch({
headless: false,
args: ['--enable-unsafe-webgpu', '--enable-features=Vulkan'],
});
const ctx = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
});
const page = await ctx.newPage();
page.on('pageerror', (err) => console.error(' pageerror:', err.message));
try {
await page.goto(url, { waitUntil: 'load' });
await page.waitForTimeout(SETTLE_MS);
// The page-side function is written as a plain Function from source so
// tsx's transpile-time `__name(fn, "...")` instrumentation can't sneak
// into the browser context (where `__name` doesn't exist). Type safety
// is sacrificed inside the body — the outer `as` keeps the contract.
const PAGE_BENCH_SRC = `return new Promise(function(resolve){
var dts = [];
var last = performance.now();
var start = last;
var tick = function(now){
var dt = now - last;
if (dt > 0) dts.push(dt);
last = now;
if (now - start >= durMs) {
var sorted = dts.slice().sort(function(a,b){return a-b;});
var pct = function(p){
var i = Math.max(0, Math.min(sorted.length - 1, Math.floor(p * sorted.length)));
return sorted[i] || 0;
};
var fps = function(n){ return n > 0 ? 1000 / n : 0; };
var minDt = sorted[0] || 0;
var maxDt = sorted[sorted.length - 1] || 0;
resolve({
frames: dts.length,
ms: now - start,
fps: (dts.length * 1000) / (now - start),
min: fps(maxDt),
max: fps(minDt),
p50: fps(pct(0.5)),
p95: fps(pct(0.05)),
});
} else {
requestAnimationFrame(tick);
}
};
requestAnimationFrame(tick);
});`;
const pageBench = new Function('durMs', PAGE_BENCH_SRC) as (
durMs: number,
) => Promise<FrameSample>;
const sample = (await page.evaluate(pageBench, SAMPLE_MS)) as FrameSample;
console.log(` preset=${PRESET} agents=${AGENTS}`);
console.log(` frames=${sample.frames} window=${sample.ms.toFixed(0)}ms`);
console.log(` fps avg=${sample.fps.toFixed(1)}`);
console.log(` p50=${sample.p50.toFixed(1)} p95=${sample.p95.toFixed(1)}`);
console.log(` min=${sample.min.toFixed(1)} max=${sample.max.toFixed(1)}`);
} finally {
await ctx.close();
await browser.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});