@homie-rocks/studio 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/perf.mjs ADDED
@@ -0,0 +1,680 @@
1
+ /**
2
+ * `homie-studio perf <game> --url <site>` — how fast a game runs, measured the same way every time, so a change can be
3
+ * judged against the build before it (`perf compare`, lib/perf-stats.mjs).
4
+ *
5
+ * ONE RUN is two browsers in a fresh room of their own (`?room=perf-…`): the first to arrive is the host (it runs the
6
+ * rules, the bots and the snapshots), the second a replica. Both are the same device:
7
+ *
8
+ * computer 1280x800 at 2x, this machine's CPU and GPU
9
+ * phone 390x844 at 3x, touch, a phone's user agent, Chrome's CPU throttle at 4x (`--cpu`) and a 4G connection
10
+ * (9 Mbit/s down, 85 ms): an EMULATED phone, on this computer's GPU. It ranks changes; it is not a phone's
11
+ * frame rate. The throttle suspends the page's thread in slices, so it stretches work less than its
12
+ * rate on a fast computer (on an Apple M4, "4x" measured between 2.4x and 3.8x): each run times the same
13
+ * loop of arithmetic before and after the throttle and records the real slow-down as `cpuMeasured`.
14
+ *
15
+ * Each browser plays (the same seeded presses every run) through a warm-up and then a measured window, and the run
16
+ * writes, per browser (host and replica):
17
+ *
18
+ * load ms from opening the page to the game's first animation frame, to a seat, and to playable (seated with a
19
+ * body that the game's port probe reports, or seated and drawing when it has none); what the game's own
20
+ * files weighed on the wire, the biggest first
21
+ * frames the time between animation frames: median, 95th and 99th percentile, the worst, and the share of frames
22
+ * over 33 ms (a dropped frame at 60 Hz, twice) and over 50 ms (a hitch anyone sees)
23
+ * work the game's JavaScript inside each animation frame (every requestAnimationFrame callback, timed)
24
+ * busy the page's whole main thread per frame (Chrome's TaskDuration: scripts, style, layout, socket messages)
25
+ * heap the JavaScript heap after a forced garbage collection, at the start and the end of the window
26
+ * net netplay messages and kilobytes a second, each way, on the room's socket, and the helper's own counters
27
+ *
28
+ * `--profile` adds a CPU profile of each browser in a second window (never the measured one: a profiler slows what it
29
+ * watches), as a .cpuprofile (Chrome DevTools opens it) and a summary of the hottest functions, read through the
30
+ * game's source map when the build kept one (`homie-studio build --maps`).
31
+ *
32
+ * LOAD. The computer is shared: before every run the 1-minute load average per core must be under `maxLoad` (0.8);
33
+ * it waits up to 3 minutes, and a run taken anyway says `loaded: true` (compare leaves it out). Every run records the
34
+ * load before and after and how busy all the cores were during its window.
35
+ *
36
+ * Software rendering (SwiftShader: a Linux VM, a cloud session) draws a WebGL game at a few frames a second: such a
37
+ * run says `blocked` and compare leaves it out. Nothing here is judged on a software renderer.
38
+ *
39
+ * Results are files (one JSON per run, screenshots, profiles) under `.perf/<game>/<time>/` (git-ignored); the command
40
+ * prints their paths and a few medians, never the data itself.
41
+ */
42
+ import { createHash } from 'node:crypto';
43
+ import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
44
+ import { cpus, loadavg, tmpdir, totalmem } from 'node:os';
45
+ import { join, relative, resolve } from 'node:path';
46
+ import { gzipSync } from 'node:zlib';
47
+ import { SOFTWARE_GL, chromeArgs, findChrome, noChrome } from './chrome.mjs';
48
+ import { LAUNCH_TIMEOUT_MS } from './check.mjs';
49
+ import { judge, judgePaired, round, summarize } from './perf-stats.mjs';
50
+ import { sourceMapLookup, summarizeProfile } from './perf-profile.mjs';
51
+
52
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
53
+ const T = (p, ms, v = null) => Promise.race([Promise.resolve(p).catch(() => v), new Promise((r) => setTimeout(() => r(v), ms))]);
54
+
55
+ const PHONE_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36';
56
+
57
+ export const PERF_DEVICES = {
58
+ computer: {
59
+ label: 'a computer: 1280x800 at 2x, this machine\'s CPU and GPU',
60
+ viewport: { width: 1280, height: 800, deviceScaleFactor: 2 },
61
+ cpu: 1,
62
+ network: null,
63
+ },
64
+ phone: {
65
+ label: 'an emulated phone: 390x844 at 3x, touch, Chrome\'s CPU throttle at {cpu}x (each run measures what that really is here: `cpuMeasured`), 4G (9 Mbit/s down, 1.5 up, 85 ms); this computer\'s GPU, so it ranks changes and is not a real phone\'s frame rate',
66
+ viewport: { width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true },
67
+ cpu: 4,
68
+ network: { offline: false, latency: 85, downloadThroughput: Math.round(9e6 / 8), uploadThroughput: Math.round(1.5e6 / 8) },
69
+ ua: PHONE_UA,
70
+ },
71
+ };
72
+
73
+ /** A device's words, with the phone's CPU slow-down filled in. */
74
+ export const deviceLabel = (device, cpu = 4) => PERF_DEVICES[device].label.replace('{cpu}', String(cpu));
75
+
76
+ /** The metric compare judges when none is named, and the guards that must not get worse. */
77
+ export const DEFAULT_GOAL = 'phone.host.frame.p95';
78
+
79
+ /**
80
+ * Runs in every frame of the page before any of its scripts (Page.addScriptToEvaluateOnNewDocument). In the game's
81
+ * frame it times every requestAnimationFrame callback: the frame's timestamp and the milliseconds its callbacks took,
82
+ * in a ring of 32,768 frames. A game that captured requestAnimationFrame before this ran cannot have: this runs first.
83
+ */
84
+ const INSTRUMENT = () => {
85
+ if (window.__perf) return;
86
+ const game = /\/__game(?:\/|$)/.test(location.pathname);
87
+ const N = 32768;
88
+ const ts = new Float64Array(N);
89
+ const work = new Float32Array(N);
90
+ let n = 0;
91
+ let cur = -1;
92
+ let acc = 0;
93
+ let first = null;
94
+ if (game && typeof window.requestAnimationFrame === 'function') {
95
+ const raf = window.requestAnimationFrame.bind(window);
96
+ window.requestAnimationFrame = function requestAnimationFrame(cb) {
97
+ return raf((t) => {
98
+ if (t !== cur) { if (cur >= 0) { ts[n % N] = cur; work[n % N] = acc; n++; } cur = t; acc = 0; if (first === null) first = performance.timeOrigin + t; }
99
+ const s = performance.now();
100
+ try { cb(t); } finally { acc += performance.now() - s; }
101
+ });
102
+ };
103
+ }
104
+ let longN = 0;
105
+ let longMs = 0;
106
+ try { new PerformanceObserver((list) => { for (const e of list.getEntries()) { longN++; longMs += e.duration; } }).observe({ type: 'longtask', buffered: true }); } catch { /* no long task timing */ }
107
+ window.__perf = {
108
+ game,
109
+ origin: performance.timeOrigin,
110
+ first: () => first,
111
+ count: () => n,
112
+ take: (from) => { const t = []; const w = []; for (let i = Math.max(from, n - N); i < n; i++) { t.push(ts[i % N]); w.push(work[i % N]); } return { t, w }; },
113
+ long: () => ({ n: longN, ms: longMs }),
114
+ };
115
+ };
116
+
117
+ /** A small seeded random number generator: every run presses the same way. */
118
+ function rng(seed) {
119
+ let s = seed >>> 0;
120
+ return () => { s = (s + 0x6d2b79f5) >>> 0; let t = Math.imul(s ^ (s >>> 15), s | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
121
+ }
122
+
123
+ const gameFrameOf = (page) => page.frames().find((f) => /\/__game(?:\/|\?|$)/.test(f.url())) ?? null;
124
+ const inGame = (h, fn, arg) => { const f = gameFrameOf(h.page); return f ? T(f.evaluate(fn, arg), 8000) : Promise.resolve(null); };
125
+ const metricsOf = async (cdp) => { const m = await T(cdp.send('Performance.getMetrics'), 8000); return m ? Object.fromEntries(m.metrics.map((x) => [x.name, x.value])) : {}; };
126
+
127
+ /** The 1-minute load average per core, waiting up to `waitMs` for it to fall under `max`. */
128
+ export async function waitCalm({ max = 0.8, waitMs = 180_000, log = () => {} } = {}) {
129
+ const cores = cpus().length;
130
+ const started = Date.now();
131
+ let perCore = loadavg()[0] / cores;
132
+ let said = false;
133
+ while (perCore > max && Date.now() - started < waitMs) {
134
+ if (!said) { log(`waiting for a calmer computer: load ${(perCore * cores).toFixed(1)} on ${cores} cores (under ${(max * cores).toFixed(1)} wanted)`); said = true; }
135
+ await sleep(10_000);
136
+ perCore = loadavg()[0] / cores;
137
+ }
138
+ return { perCore: round(perCore, 2), load1: round(loadavg()[0], 2), cores, waitedMs: Date.now() - started, calm: perCore <= max };
139
+ }
140
+
141
+ /** How busy every core of this computer was between two os.cpus() readings, 0-100. */
142
+ function busyBetween(a, b) {
143
+ let idle = 0;
144
+ let total = 0;
145
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
146
+ const ta = a[i].times; const tb = b[i].times;
147
+ const d = (k) => tb[k] - ta[k];
148
+ const all = d('user') + d('nice') + d('sys') + d('idle') + d('irq');
149
+ total += all; idle += d('idle');
150
+ }
151
+ return total ? round((1 - idle / total) * 100, 1) : null;
152
+ }
153
+
154
+ /** Open one browser of a run on the play page, instrumented, and wait until it is playable. */
155
+ async function openPlayer(puppeteer, chrome, device, playUrl, label, log, cpu) {
156
+ const dev = PERF_DEVICES[device];
157
+ const rate = device === 'phone' ? cpu : dev.cpu;
158
+ const profileDir = mkdtempSync(join(tmpdir(), 'homie-studio-perf-'));
159
+ const vp = dev.viewport;
160
+ const browser = await puppeteer.launch({
161
+ executablePath: chrome, headless: true, userDataDir: profileDir, timeout: LAUNCH_TIMEOUT_MS, protocolTimeout: 180_000,
162
+ args: [...chromeArgs(), '--mute-audio', '--autoplay-policy=no-user-gesture-required', `--window-size=${vp.width},${vp.height}`, '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--force-color-profile=srgb', '--enable-precise-memory-info'],
163
+ });
164
+ const h = { label, device, browser, profileDir, page: null, cdp: null, errors: [], requests: new Map(), sockets: new Map(), ws: { net: { out: 0, in: 0, outBytes: 0, inBytes: 0 }, other: { out: 0, in: 0, outBytes: 0, inBytes: 0 } } };
165
+ try {
166
+ const page = await browser.newPage();
167
+ h.page = page;
168
+ await page.setViewport(vp);
169
+ const ua = dev.ua ?? await browser.userAgent();
170
+ // Tagged as the house's QA, so the studio's own stats never count a perf run as a visitor.
171
+ await page.setUserAgent(`${ua} homie-studio-check homie-studio-perf`);
172
+ page.on('pageerror', (e) => { if (h.errors.length < 30) h.errors.push(String(e?.message ?? e).slice(0, 240)); });
173
+ const cdp = await page.createCDPSession();
174
+ h.cdp = cdp;
175
+ await cdp.send('Network.enable');
176
+ await cdp.send('Performance.enable');
177
+ if (rate > 1) {
178
+ // What the slow-down really is on this computer: the same loop of arithmetic before and after (Chrome's throttle
179
+ // suspends the page's thread in slices, so a "4x" can stretch work less than 4 times on a fast machine).
180
+ const loop = () => page.evaluate(() => { const t = performance.now(); let x = 0; for (let i = 0; i < 4e6; i++) x += Math.sqrt(i); return [performance.now() - t, x > 0]; }).then(([ms]) => ms).catch(() => null);
181
+ const fast = [await loop(), await loop(), await loop()].filter(Number.isFinite).sort((x, y) => x - y)[1] ?? null;
182
+ await cdp.send('Emulation.setCPUThrottlingRate', { rate });
183
+ const slow = [await loop(), await loop(), await loop()].filter(Number.isFinite).sort((x, y) => x - y)[1] ?? null;
184
+ h.cpuMeasured = fast && slow ? round(slow / fast, 2) : null;
185
+ }
186
+ if (dev.network) await cdp.send('Network.emulateNetworkConditions', dev.network);
187
+ cdp.on('Network.requestWillBeSent', (e) => { if (!h.requests.has(e.requestId)) h.requests.set(e.requestId, { url: e.request.url, type: e.type ?? null, bytes: 0, done: false }); });
188
+ cdp.on('Network.loadingFinished', (e) => { const r = h.requests.get(e.requestId); if (r) { r.bytes = e.encodedDataLength; r.done = true; } });
189
+ cdp.on('Network.webSocketCreated', (e) => h.sockets.set(e.requestId, /\/__net(?:\?|$)/.test(e.url) ? 'net' : 'other'));
190
+ const wsBytes = (r) => (r.opcode === 2 ? Math.floor((String(r.payloadData).length * 3) / 4) : Buffer.byteLength(String(r.payloadData)));
191
+ cdp.on('Network.webSocketFrameSent', (e) => { const w = h.ws[h.sockets.get(e.requestId) ?? 'other']; w.out++; w.outBytes += wsBytes(e.response); });
192
+ cdp.on('Network.webSocketFrameReceived', (e) => { const w = h.ws[h.sockets.get(e.requestId) ?? 'other']; w.in++; w.inBytes += wsBytes(e.response); });
193
+ await page.evaluateOnNewDocument(INSTRUMENT);
194
+ await page.goto(playUrl, { waitUntil: 'domcontentloaded', timeout: 90_000 });
195
+ const origin = await T(page.evaluate(() => performance.timeOrigin), 8000);
196
+ let seatedAt = null; let playableAt = null; let shell = null; let body = null;
197
+ const deadline = Date.now() + 90_000;
198
+ while (Date.now() < deadline && playableAt === null) {
199
+ shell = await T(page.evaluate(() => { const s = window.__shell; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null } : null; }), 5000);
200
+ const now = Date.now();
201
+ if (seatedAt === null && shell?.room && Number.isInteger(shell.seat) && shell.role) seatedAt = now;
202
+ const g = await inGame(h, () => {
203
+ const p = window.__homiePort;
204
+ let self = null;
205
+ if (p && p.view !== 'board') { const r = p.rows(p.now() - 400); const last = r[r.length - 1]; self = last ? Number.isFinite(last[1]) : false; }
206
+ return { first: window.__perf?.first?.() ?? null, port: Boolean(p), view: p?.view ?? null, self };
207
+ });
208
+ if (seatedAt !== null && g?.first !== null && g?.first !== undefined) {
209
+ // A body the game reports (its port probe), or no body to wait for (a board, or a game with no probe).
210
+ if (!g.port || g.view === 'board' || g.self) { playableAt = now; body = g.port && g.view !== 'board' ? 'reported' : 'none to report'; }
211
+ else if (now - seatedAt > 10_000) { playableAt = null; body = 'never reported by the port probe in 10 s'; break; }
212
+ }
213
+ await sleep(50);
214
+ }
215
+ const g = await inGame(h, () => {
216
+ const nav = performance.getEntriesByType('navigation')[0];
217
+ const paint = Object.fromEntries(performance.getEntriesByType('paint').map((p) => [p.name, p.startTime]));
218
+ let renderer = null;
219
+ try { const c = document.createElement('canvas'); const gl = c.getContext('webgl2') || c.getContext('webgl'); const d = gl && gl.getExtension('WEBGL_debug_renderer_info'); renderer = gl ? String(d ? gl.getParameter(d.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)) : 'no WebGL'; } catch { renderer = null; }
220
+ return { origin: performance.timeOrigin, first: window.__perf?.first?.() ?? null, dcl: nav ? nav.domContentLoadedEventEnd : null, loaded: nav ? nav.loadEventEnd : null, fcp: paint['first-contentful-paint'] ?? null, renderer, keys: window.__homiePort?.keys ?? null, thumb: window.__homiePort?.thumb ?? null, view: window.__homiePort?.view ?? null };
221
+ });
222
+ const at = (epoch) => (epoch && origin ? Math.round(epoch - origin) : null);
223
+ const files = [...h.requests.values()].filter((r) => r.done);
224
+ const gameFiles = files.filter((r) => /\/__game\//.test(r.url));
225
+ h.loadInfo = {
226
+ firstFrameMs: at(g?.first),
227
+ seatedMs: at(seatedAt),
228
+ playableMs: at(playableAt),
229
+ body,
230
+ gameDomReadyMs: g?.dcl !== null && g?.dcl !== undefined && g?.origin ? at(g.origin + g.dcl) : null,
231
+ gameFirstPaintMs: g?.fcp !== null && g?.fcp !== undefined && g?.origin ? at(g.origin + g.fcp) : null,
232
+ requests: files.length,
233
+ kb: round(files.reduce((s, r) => s + r.bytes, 0) / 1024, 1),
234
+ gameRequests: gameFiles.length,
235
+ gameKb: round(gameFiles.reduce((s, r) => s + r.bytes, 0) / 1024, 1),
236
+ biggest: [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 8).map((r) => ({ path: pathOf(r.url), type: r.type, kb: round(r.bytes / 1024, 1) })),
237
+ };
238
+ h.shell = shell;
239
+ h.renderer = g?.renderer ?? null;
240
+ h.keys = g?.keys ?? null;
241
+ h.thumb = g?.thumb ?? null;
242
+ h.view = g?.view ?? null;
243
+ h.requestsAtPlayable = h.requests.size;
244
+ log(`${label}: ${shell?.role ?? 'no role'} in room ${shell?.room ?? '?'}, playable at ${h.loadInfo.playableMs ?? '?'} ms`);
245
+ return h;
246
+ } catch (error) {
247
+ await closePlayer(h);
248
+ throw error;
249
+ }
250
+ }
251
+
252
+ async function closePlayer(h) {
253
+ try { await T(h.browser.close(), 8000); } catch { /* */ }
254
+ try { h.browser.process()?.kill('SIGKILL'); } catch { /* */ }
255
+ rmSync(h.profileDir, { recursive: true, force: true });
256
+ }
257
+
258
+ const pathOf = (url) => { try { return new URL(url).pathname; } catch { return String(url).slice(0, 120); } };
259
+
260
+ /** Play the same way every run: hold a direction most of a second, now and then the action, until `stop.on` is false. */
261
+ async function drive(h, seed, stop) {
262
+ const r = rng(seed);
263
+ const keys = h.keys ?? { up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' };
264
+ const dirs = ['up', 'right', 'down', 'left'];
265
+ const vp = PERF_DEVICES[h.device].viewport;
266
+ const touch = Boolean(vp.hasTouch);
267
+ const [fx, fy] = Array.isArray(h.thumb) ? h.thumb : [0.24, 0.74];
268
+ while (stop.on) {
269
+ const d = dirs[Math.floor(r() * 4)];
270
+ const hold = 400 + Math.floor(r() * 700);
271
+ try {
272
+ if (touch) {
273
+ const x = Math.round(vp.width * fx); const y = Math.round(vp.height * fy);
274
+ const v = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[d];
275
+ await h.page.touchscreen.touchStart(x, y);
276
+ for (let k = 1; k <= 5; k++) { await h.page.touchscreen.touchMove(x + v[0] * 14 * k, y + v[1] * 14 * k); await sleep(16); }
277
+ await sleep(hold);
278
+ await h.page.touchscreen.touchEnd();
279
+ if (r() < 0.3) await h.page.touchscreen.tap(Math.round(vp.width * 0.82), Math.round(vp.height * 0.78));
280
+ } else {
281
+ await h.page.keyboard.down(keys[d]); await sleep(hold); await h.page.keyboard.up(keys[d]);
282
+ if (r() < 0.3) await h.page.keyboard.press('Space');
283
+ }
284
+ } catch { /* a closing page */ }
285
+ await sleep(60 + Math.floor(r() * 120));
286
+ }
287
+ }
288
+
289
+ /** What a browser's counters say now (the start or end of the measured window). */
290
+ async function reading(h, { gc = false } = {}) {
291
+ if (gc) await T(h.cdp.send('HeapProfiler.collectGarbage'), 10_000);
292
+ const m = await metricsOf(h.cdp);
293
+ const count = await inGame(h, () => window.__perf?.count?.() ?? 0);
294
+ const long = await inGame(h, () => window.__perf?.long?.() ?? null);
295
+ return { at: Date.now(), m, count: count ?? 0, long, ws: JSON.parse(JSON.stringify(h.ws)) };
296
+ }
297
+
298
+ /** The measured window of one browser, from two readings. */
299
+ async function windowOf(h, a, b, seconds) {
300
+ const span = (b.at - a.at) / 1000 || seconds;
301
+ const taken = await inGame(h, (from) => window.__perf?.take?.(from) ?? null, a.count);
302
+ const t = taken?.t ?? [];
303
+ const w = taken?.w ?? [];
304
+ const gaps = [];
305
+ for (let i = 1; i < t.length; i++) gaps.push(t[i] - t[i - 1]);
306
+ const frames = gaps.length;
307
+ const share = (ms) => (frames ? round((gaps.filter((g) => g > ms).length / frames) * 100, 2) : null);
308
+ const q = (xs, p) => { const s = [...xs].sort((x, y) => x - y); return s.length ? round(s[Math.min(s.length - 1, Math.floor(p * (s.length - 1)))], 2) : null; };
309
+ const d = (k) => (Number.isFinite(b.m[k]) && Number.isFinite(a.m[k]) ? b.m[k] - a.m[k] : null);
310
+ const ws = (k, dir) => round((b.ws[k][dir] - a.ws[k][dir]) / span, 1);
311
+ const wsKb = (k, dir) => round((b.ws[k][`${dir}Bytes`] - a.ws[k][`${dir}Bytes`]) / 1024 / span, 2);
312
+ return {
313
+ seconds: round(span, 1),
314
+ frames: { n: frames, fps: span ? round(frames / span, 1) : null, p50: q(gaps, 0.5), p95: q(gaps, 0.95), p99: q(gaps, 0.99), max: frames ? round(Math.max(...gaps), 1) : null, over33: share(33.4), over50: share(50) },
315
+ work: { p50: q(w, 0.5), p95: q(w, 0.95), mean: w.length ? round(w.reduce((s, x) => s + x, 0) / w.length, 3) : null, max: w.length ? round(Math.max(...w), 2) : null },
316
+ main: {
317
+ busyPerFrame: frames && d('TaskDuration') !== null ? round((d('TaskDuration') * 1000) / frames, 3) : null,
318
+ busyPct: d('TaskDuration') !== null ? round((d('TaskDuration') / span) * 100, 1) : null,
319
+ scriptMsPerS: d('ScriptDuration') !== null ? round((d('ScriptDuration') * 1000) / span, 1) : null,
320
+ layoutMsPerS: d('LayoutDuration') !== null ? round((d('LayoutDuration') * 1000) / span, 2) : null,
321
+ styleMsPerS: d('RecalcStyleDuration') !== null ? round((d('RecalcStyleDuration') * 1000) / span, 2) : null,
322
+ longTasks: a.long && b.long ? { n: b.long.n - a.long.n, ms: round(b.long.ms - a.long.ms, 1) } : null,
323
+ },
324
+ heap: { startMb: round((a.m.JSHeapUsedSize ?? NaN) / 1048576, 2), endMb: round((b.m.JSHeapUsedSize ?? NaN) / 1048576, 2), growthMbPerMin: round((((b.m.JSHeapUsedSize ?? NaN) - (a.m.JSHeapUsedSize ?? NaN)) / 1048576) / (span / 60), 2), nodes: b.m.Nodes ?? null, listeners: b.m.JSEventListeners ?? null },
325
+ net: { msgsOut: ws('net', 'out'), msgsIn: ws('net', 'in'), kbOut: wsKb('net', 'out'), kbIn: wsKb('net', 'in'), otherMsgs: round(ws('other', 'out') + ws('other', 'in'), 1) },
326
+ };
327
+ }
328
+
329
+ /** A CPU profile of one browser for `seconds` (a window of its own, after the measured one). */
330
+ async function profileOf(h, seconds, file) {
331
+ await h.cdp.send('Profiler.enable');
332
+ await h.cdp.send('Profiler.setSamplingInterval', { interval: 250 });
333
+ await h.cdp.send('Profiler.start');
334
+ await sleep(seconds * 1000);
335
+ const { profile } = await h.cdp.send('Profiler.stop');
336
+ writeFileSync(file, JSON.stringify(profile));
337
+ return profile;
338
+ }
339
+
340
+ /** The game's source map, when the build kept one for the exact main.js being served (`homie-studio build --maps`). */
341
+ function mapsFor(root, game) {
342
+ if (!root) return [];
343
+ const dir = join(root, '.studio', 'maps', game);
344
+ const mapFile = join(dir, 'main.js.map');
345
+ const built = join(root, 'site', 'dist', 'games', game, 'assets', 'main.js');
346
+ if (!existsSync(mapFile) || !existsSync(built)) return [];
347
+ try {
348
+ const want = readFileSync(join(dir, 'main.js.sha256'), 'utf8').trim();
349
+ if (sha256(readFileSync(built)) !== want) return [];
350
+ const lookup = sourceMapLookup(JSON.parse(readFileSync(mapFile, 'utf8')));
351
+ return [{ match: (url) => new RegExp(`/${game}/__game/assets/main\\.js(?:\\?|$)`).test(url), lookup }];
352
+ } catch { return []; }
353
+ }
354
+
355
+ export const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
356
+
357
+ /** One digest of a built game's files (site/dist/games/<id>), so a run says exactly which build it measured. */
358
+ export function buildDigest(root, game) {
359
+ const dir = root ? join(root, 'site', 'dist', 'games', game) : null;
360
+ if (!dir || !existsSync(dir)) return null;
361
+ const h = createHash('sha256');
362
+ for (const f of listFiles(dir).filter((f) => !f.startsWith('_landing/')).sort()) h.update(`${f}\0${sha256(readFileSync(join(dir, f)))}\n`);
363
+ return h.digest('hex').slice(0, 16);
364
+ }
365
+
366
+ function listFiles(dir, rel = '') {
367
+ const out = [];
368
+ for (const e of readdirSync(join(dir, rel), { withFileTypes: true })) {
369
+ const p = rel ? `${rel}/${e.name}` : e.name;
370
+ if (e.isDirectory()) out.push(...listFiles(dir, p)); else if (e.isFile()) out.push(p);
371
+ }
372
+ return out;
373
+ }
374
+
375
+ /**
376
+ * ONE RUN on one device: two browsers in a fresh room, a warm-up, the measured window, then (with `profile`) a
377
+ * profiling window. Writes `<out>/<device>-<k>.json` and its screenshots; returns the run.
378
+ */
379
+ async function oneRun({ puppeteer, chrome, root, url, game, device, k, seconds, warm, profile, out, maxLoad, waitLoadMs, log, cpu, pair }) {
380
+ const calm = await waitCalm({ max: maxLoad, waitMs: waitLoadMs, log });
381
+ const room = `perf-${Date.now().toString(36)}-${Math.floor(Math.random() * 46656).toString(36)}`;
382
+ const playUrl = `${url}/${game}/play?room=${room}`;
383
+ const build = buildDigest(root, game);
384
+ const hs = [];
385
+ const stop = { on: true };
386
+ const name = `${device}-${k}`;
387
+ const run = { v: 1, kind: 'homie-perf-run', game, url, device, deviceLabel: deviceLabel(device, cpu), cpu: device === 'phone' ? cpu : 1, ...(pair ? { pair: String(pair).slice(0, 40) } : {}), room, at: new Date().toISOString(), seconds, warm, build, machine: machine(), load: { before: calm }, blocked: null, browsers: [] };
388
+ try {
389
+ hs.push(await openPlayer(puppeteer, chrome, device, playUrl, 'first browser', log, cpu));
390
+ hs.push(await openPlayer(puppeteer, chrome, device, playUrl, 'second browser', log, cpu));
391
+ run.chrome = await T(hs[0].browser.version(), 5000);
392
+ if (hs[0].cpuMeasured) run.cpuMeasured = hs[0].cpuMeasured;
393
+ run.renderer = hs[0].renderer;
394
+ if (run.renderer && SOFTWARE_GL.test(run.renderer)) run.blocked = `software renderer (${run.renderer}): a frame rate here is not a person's; nothing in this run is judged`;
395
+ if (hs[0].shell?.room !== hs[1].shell?.room) run.blocked = `the two browsers landed in different rooms (${hs[0].shell?.room}, ${hs[1].shell?.room})`;
396
+ const players = hs.map((h, i) => drive(h, 1000 + i * 7919 + k * 31, stop));
397
+ await sleep(warm * 1000);
398
+ // A garbage collection before the window (outside it), so the heap's growth over the window is the game's own.
399
+ const a = await Promise.all(hs.map((h) => reading(h, { gc: true })));
400
+ const cpu0 = cpus();
401
+ await sleep(seconds * 1000);
402
+ const cpu1 = cpus();
403
+ const b = await Promise.all(hs.map((h) => reading(h)));
404
+ const after = await Promise.all(hs.map((h) => reading(h, { gc: true })));
405
+ run.load.busyPct = busyBetween(cpu0, cpu1);
406
+ for (const [i, h] of hs.entries()) {
407
+ const win = await windowOf(h, a[i], b[i], seconds);
408
+ win.heap.afterGcMb = round((after[i].m.JSHeapUsedSize ?? NaN) / 1048576, 2);
409
+ win.heap.gcGrowthMbPerMin = round((((after[i].m.JSHeapUsedSize ?? NaN) - (a[i].m.JSHeapUsedSize ?? NaN)) / 1048576) / (win.seconds / 60), 2);
410
+ const shell = await T(h.page.evaluate(() => { const s = window.__shell; if (!s) return null; const t = s.stats ?? {}; return { role: t.role ?? null, seat: s.seat ?? null, peers: t.peers ?? null, snapHzOut: t.snapHzOut ?? null, snapHzIn: t.snapHzIn ?? null, inputHzOut: t.inputHzOut ?? null, inputHzIn: t.inputHzIn ?? null, lastSnapBytes: t.lastSnapBytes ?? null, maxSnapBytes: t.maxSnapBytes ?? null, bytesOutPerS: t.bytesOutPerS ?? null, bytesInPerS: t.bytesInPerS ?? null, rtt: t.rtt ?? null, interpDelay: t.interpDelay ?? null, starvedPct: t.starvedPct ?? null, reconnects: t.reconnects ?? null }; }), 5000);
411
+ const role = shell?.role === 'host' ? 'host' : 'replica';
412
+ const shot = join(out, `${name}-${role}.png`);
413
+ const vp = PERF_DEVICES[device].viewport;
414
+ const scale = Math.min(1, 640 / (vp.width * vp.deviceScaleFactor));
415
+ await T(h.page.screenshot({ path: shot, type: 'png', clip: { x: 0, y: 0, width: vp.width, height: vp.height, scale } }), 15_000);
416
+ run.browsers.push({ role, seat: shell?.seat ?? null, load: h.loadInfo, ...win, netplay: shell, errors: h.errors.slice(0, 10), screenshot: existsSync(shot) ? relative(out, shot) : null });
417
+ }
418
+ if (!run.browsers.some((x) => x.role === 'host')) run.blocked ??= 'neither browser became the room\'s host';
419
+ if (profile) {
420
+ // A window of its own, still playing: a profiler slows what it watches, so the measured window above never has one.
421
+ const maps = mapsFor(root, game);
422
+ const isGame = (u) => /\/__game\//.test(u);
423
+ const secs = Math.min(10, seconds);
424
+ const profs = await Promise.all(hs.map((h, i) => profileOf(h, secs, join(out, `${name}-${run.browsers[i].role}.cpuprofile`))));
425
+ for (const [i, p] of profs.entries()) run.browsers[i].profile = { file: `${name}-${run.browsers[i].role}.cpuprofile`, seconds: secs, mapped: maps.length > 0, ...summarizeProfile(p, { maps, game: isGame }) };
426
+ }
427
+ stop.on = false;
428
+ await Promise.all(players);
429
+ } finally {
430
+ stop.on = false;
431
+ for (const h of hs) await closePlayer(h);
432
+ }
433
+ run.load.after = { load1: round(loadavg()[0], 2), perCore: round(loadavg()[0] / cpus().length, 2) };
434
+ run.loaded = !calm.calm;
435
+ const file = join(out, `${name}.json`);
436
+ writeFileSync(file, `${JSON.stringify(run, null, 1)}\n`);
437
+ return { file, run };
438
+ }
439
+
440
+ function machine() {
441
+ const c = cpus();
442
+ return { cpu: c[0]?.model ?? null, cores: c.length, memGb: Math.round(totalmem() / 1073741824), platform: process.platform };
443
+ }
444
+
445
+ /** Add `.perf/` to the studio's .gitignore once (runs are this computer's own). */
446
+ function ignorePerf(root) {
447
+ if (!root) return;
448
+ const gi = join(root, '.gitignore');
449
+ const text = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
450
+ if (!/^\.perf\/?$/m.test(text)) appendFileSync(gi, `${text.endsWith('\n') || !text ? '' : '\n'}# Performance runs (homie-studio perf): numbers, screenshots and CPU profiles of each run.\n.perf/\n`);
451
+ }
452
+
453
+ /**
454
+ * `homie-studio perf <game>`: `runs` runs on each device, written under `out`. Returns paths and medians only.
455
+ */
456
+ export async function perfRun({ root = null, url, game, devices = ['computer', 'phone'], runs = 1, seconds = 15, warm = 3, profile = false, out = null, maxLoad = 0.8, waitLoadMs = 180_000, cpu = 4, pair = null, log = () => {} }) {
457
+ if (!url || !game) return { ok: false, command: 'perf', why: 'usage: homie-studio perf <game> --url <site> (the local dev address, or the live site)' };
458
+ const base = String(url).replace(/\/+$/, '');
459
+ const bad = devices.filter((d) => !PERF_DEVICES[d]);
460
+ if (bad.length) return { ok: false, command: 'perf', why: `unknown device ${bad.join(', ')} (computer, phone)` };
461
+ const chrome = findChrome();
462
+ if (!chrome) return { ok: false, command: 'perf', why: noChrome() };
463
+ let puppeteer;
464
+ try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'perf', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
465
+ const alive = await fetch(`${base}/${game}/play`, { signal: AbortSignal.timeout(15_000) }).then((r) => r.ok).catch(() => false);
466
+ if (!alive) return { ok: false, command: 'perf', why: `${base}/${game}/play does not answer: start the site (npm run dev, as a background task) or check the address` };
467
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*/, '');
468
+ const dir = resolve(out ?? join(root ?? process.cwd(), '.perf', game, stamp));
469
+ mkdirSync(dir, { recursive: true });
470
+ ignorePerf(root);
471
+ if (root && existsSync(join(root, 'site', 'dist', 'games', game))) writeFileSync(join(dir, 'sizes.json'), `${JSON.stringify(perfSizes(root, game), null, 1)}\n`);
472
+ const files = [];
473
+ const done = [];
474
+ for (const device of devices) {
475
+ for (let k = 1; k <= runs; k++) {
476
+ const n = nextIndex(dir, device, k);
477
+ log(`… ${device} run ${k} of ${runs}`);
478
+ const r = await oneRun({ puppeteer, chrome, root, url: base, game, device, k: n, seconds, warm, profile, out: dir, maxLoad, waitLoadMs, log, cpu, pair: pair && runs === 1 ? pair : null });
479
+ files.push(r.file);
480
+ done.push(r.run);
481
+ }
482
+ }
483
+ const summary = summaryOf(done);
484
+ writeFileSync(join(dir, 'summary.json'), `${JSON.stringify(summary, null, 1)}\n`);
485
+ const blocked = done.filter((r) => r.blocked);
486
+ const loaded = done.filter((r) => r.loaded);
487
+ return {
488
+ ok: blocked.length < done.length,
489
+ command: 'perf',
490
+ game,
491
+ out: dir,
492
+ runs: files.map((f) => relative(process.cwd(), f) || f),
493
+ summary: join(dir, 'summary.json'),
494
+ sizes: existsSync(join(dir, 'sizes.json')) ? join(dir, 'sizes.json') : null,
495
+ headline: headlineOf(summary),
496
+ ...(blocked.length ? { blocked: `${blocked.length} of ${done.length} runs could not be judged: ${blocked[0].blocked}` } : {}),
497
+ ...(loaded.length ? { loaded: `${loaded.length} of ${done.length} runs started on a busy computer (load over ${maxLoad} per core): compare leaves them out` } : {}),
498
+ profiles: profile ? done.flatMap((r) => r.browsers.map((b) => (b.profile ? join(dir, b.profile.file) : null))).filter(Boolean) : undefined,
499
+ };
500
+ }
501
+
502
+ /** The next free run number for a device in a folder (runs can be added to a folder later). */
503
+ function nextIndex(dir, device, k) {
504
+ let n = k;
505
+ while (existsSync(join(dir, `${device}-${n}.json`))) n++;
506
+ return n;
507
+ }
508
+
509
+ /** Every number a run gives, flat: `phone.host.frame.p95`, `computer.replica.net.msgsIn`, … (all lower is better). */
510
+ export function metricsOfRun(run) {
511
+ const out = {};
512
+ for (const b of run.browsers ?? []) {
513
+ const p = `${run.device}.${b.role}`;
514
+ const put = (k, v) => { if (Number.isFinite(v)) out[`${p}.${k}`] = v; };
515
+ put('frame.p50', b.frames?.p50); put('frame.p95', b.frames?.p95); put('frame.p99', b.frames?.p99); put('frame.max', b.frames?.max);
516
+ put('frame.over33', b.frames?.over33); put('frame.over50', b.frames?.over50);
517
+ put('work.p50', b.work?.p50); put('work.p95', b.work?.p95); put('work.mean', b.work?.mean);
518
+ put('busy', b.main?.busyPerFrame); put('script', b.main?.scriptMsPerS);
519
+ put('heap', b.heap?.afterGcMb); put('heap.growth', b.heap?.gcGrowthMbPerMin);
520
+ put('load.firstFrame', b.load?.firstFrameMs); put('load.seated', b.load?.seatedMs); put('load.playable', b.load?.playableMs); put('load.gameKb', b.load?.gameKb);
521
+ put('net.msgsOut', b.net?.msgsOut); put('net.msgsIn', b.net?.msgsIn); put('net.kbOut', b.net?.kbOut); put('net.kbIn', b.net?.kbIn);
522
+ }
523
+ return out;
524
+ }
525
+
526
+ /** Medians of every metric over the runs that count (not blocked, not loaded), per metric. */
527
+ export function summaryOf(runs) {
528
+ const counted = runs.filter((r) => !r.blocked && !r.loaded);
529
+ const by = {};
530
+ for (const r of counted) for (const [k, v] of Object.entries(metricsOfRun(r))) (by[k] ??= []).push(v);
531
+ const metrics = Object.fromEntries(Object.entries(by).sort(([a], [b]) => a.localeCompare(b)).map(([k, xs]) => [k, summarize(xs)]));
532
+ const renderers = [...new Set(runs.map((r) => r.renderer).filter(Boolean))];
533
+ return { v: 1, kind: 'homie-perf-summary', runs: runs.length, counted: counted.length, devices: [...new Set(runs.map((r) => r.device))], renderers, load: runs.map((r) => ({ device: r.device, before: r.load?.before?.load1 ?? null, after: r.load?.after?.load1 ?? null, busyPct: r.load?.busyPct ?? null, loaded: Boolean(r.loaded) })), metrics };
534
+ }
535
+
536
+ /** A few medians for a person: frame time, the game's work per frame and time to playable, per device and role. */
537
+ function headlineOf(summary) {
538
+ const out = [];
539
+ for (const device of summary.devices) {
540
+ for (const role of ['host', 'replica']) {
541
+ const m = (k) => summary.metrics[`${device}.${role}.${k}`]?.median;
542
+ if (m('frame.p50') === undefined) continue;
543
+ out.push(`${device} ${role}: frames ${m('frame.p50')} ms median, ${m('frame.p95')} ms p95, ${m('frame.over50') ?? 0}% over 50 ms; game JS ${m('work.p50')} ms a frame (p95 ${m('work.p95')}); main thread ${m('busy')} ms a frame; playable at ${m('load.playable') ?? '?'} ms; netplay ${m('net.msgsOut')} out / ${m('net.msgsIn')} in a second; heap ${m('heap')} MB`);
544
+ }
545
+ }
546
+ return out;
547
+ }
548
+
549
+ /* ------------------------------------------------------------------------------------------------- sizes */
550
+
551
+ const KIND = [[/\.(m?js)$/i, 'js'], [/\.css$/i, 'css'], [/\.html?$/i, 'html'], [/\.(png|jpe?g|gif|webp|avif|svg|ico)$/i, 'image'], [/\.(mp3|ogg|wav|m4a|flac|opus)$/i, 'audio'], [/\.(mp4|webm|mov)$/i, 'video'], [/\.(glb|gltf|bin|obj|fbx|hdr|ktx2|basis)$/i, 'model'], [/\.(woff2?|ttf|otf)$/i, 'font'], [/\.(json|txt|md)$/i, 'data']];
552
+ const kindOf = (f) => KIND.find(([re]) => re.test(f))?.[1] ?? 'other';
553
+ const COMPRESSIBLE = new Set(['js', 'css', 'html', 'data', 'model']);
554
+
555
+ /**
556
+ * `homie-studio perf sizes <game>`: what a player downloads, from the built game (site/dist/games/<id>): every file,
557
+ * raw and gzipped (what a server sends when the browser accepts it), the biggest first, and with a build that kept its
558
+ * map (`build --maps`) which source modules make up the bundle. `source.json` (for remixers), `_landing/`, `hero/` and
559
+ * the cover (the landing page's) are listed apart: the game itself never loads them. What a browser really fetched is
560
+ * in each run (`load`).
561
+ */
562
+ export function perfSizes(root, game) {
563
+ const dir = join(root, 'site', 'dist', 'games', game);
564
+ if (!existsSync(dir)) return { ok: false, command: 'perf sizes', why: `no build of ${game}: run npm run build first` };
565
+ let cover = null;
566
+ try { cover = JSON.parse(readFileSync(join(root, 'games', game, 'game.json'), 'utf8')).cover ?? null; } catch { /* none */ }
567
+ const files = listFiles(dir).map((f) => {
568
+ const buf = readFileSync(join(dir, f));
569
+ const kind = kindOf(f);
570
+ // Never loaded by the game itself: the remix source, and the landing page's art (hero footage, the cover).
571
+ return { path: f, kind, bytes: buf.length, gzip: COMPRESSIBLE.has(kind) ? gzipSync(buf, { level: 6 }).length : buf.length, apart: f === 'source.json' || f.startsWith('_landing/') || f.startsWith('hero/') || f === cover };
572
+ });
573
+ const game_ = files.filter((f) => !f.apart);
574
+ const sum = (xs, k) => xs.reduce((s, x) => s + x[k], 0);
575
+ const byKind = {};
576
+ for (const f of game_) { const k = (byKind[f.kind] ??= { files: 0, bytes: 0, gzip: 0 }); k.files++; k.bytes += f.bytes; k.gzip += f.gzip; }
577
+ let modules = null;
578
+ const mapDir = join(root, '.studio', 'maps', game);
579
+ const main = join(dir, 'assets', 'main.js');
580
+ if (existsSync(join(mapDir, 'meta.json')) && existsSync(main)) {
581
+ try {
582
+ const want = readFileSync(join(mapDir, 'main.js.sha256'), 'utf8').trim();
583
+ if (want === sha256(readFileSync(main))) {
584
+ const meta = JSON.parse(readFileSync(join(mapDir, 'meta.json'), 'utf8'));
585
+ const outKey = Object.keys(meta.outputs ?? {}).find((k) => k.endsWith('main.js'));
586
+ const inputs = Object.entries(meta.outputs?.[outKey]?.inputs ?? {}).map(([p, v]) => ({ module: p.replace(/^(\.\.\/)+/, '').replace(/^.*node_modules\//, 'node_modules/'), bytes: v.bytesInOutput })).sort((a, b) => b.bytes - a.bytes);
587
+ modules = { total: inputs.reduce((s, x) => s + x.bytes, 0), top: inputs.slice(0, 12) };
588
+ }
589
+ } catch { modules = null; }
590
+ }
591
+ return {
592
+ ok: true,
593
+ command: 'perf sizes',
594
+ game,
595
+ total: { files: game_.length, bytes: sum(game_, 'bytes'), gzip: sum(game_, 'gzip') },
596
+ js: { bytes: byKind.js?.bytes ?? 0, gzip: byKind.js?.gzip ?? 0 },
597
+ byKind,
598
+ biggest: [...game_].sort((a, b) => b.bytes - a.bytes).slice(0, 12).map(({ apart, ...f }) => f),
599
+ apart: files.filter((f) => f.apart).length ? { files: files.filter((f) => f.apart).length, bytes: sum(files.filter((f) => f.apart), 'bytes'), note: 'the remix source and the landing page\'s art (hero/, the cover): never loaded by the game' } : null,
600
+ modules,
601
+ };
602
+ }
603
+
604
+ /** Size metrics, flat like a run's: `bytes.total`, `bytes.gzip`, `bytes.js`, `bytes.jsGzip` (the same every run). */
605
+ export function metricsOfSizes(s) {
606
+ if (!s?.ok) return {};
607
+ return { 'bytes.total': s.total.bytes, 'bytes.gzip': s.total.gzip, 'bytes.js': s.js.bytes, 'bytes.jsGzip': s.js.gzip };
608
+ }
609
+
610
+ /* ------------------------------------------------------------------------------------------------- compare */
611
+
612
+ /** The runs in a folder (every <device>-<n>.json), and its sizes.json. */
613
+ export function readRuns(dir) {
614
+ if (!dir || !existsSync(dir) || !statSync(dir).isDirectory()) return { runs: [], sizes: null };
615
+ const runs = readdirSync(dir).filter((f) => /^(computer|phone)-\d+\.json$/.test(f)).map((f) => { try { return JSON.parse(readFileSync(join(dir, f), 'utf8')); } catch { return null; } }).filter((r) => r?.kind === 'homie-perf-run');
616
+ let sizes = null;
617
+ try { sizes = JSON.parse(readFileSync(join(dir, 'sizes.json'), 'utf8')); } catch { /* none */ }
618
+ return { runs, sizes };
619
+ }
620
+
621
+ /** The metrics guarded by default: frame time and main-thread work of each role, time to playable and heap. */
622
+ export function defaultGuards(devices, goal) {
623
+ const g = [];
624
+ for (const d of devices) for (const role of ['host', 'replica']) g.push(`${d}.${role}.frame.p95`, `${d}.${role}.busy`, `${d}.${role}.load.playable`);
625
+ for (const d of devices) g.push(`${d}.host.heap`, `${d}.host.net.kbOut`);
626
+ return g.filter((k) => k !== goal);
627
+ }
628
+
629
+ /**
630
+ * `homie-studio perf compare <before> <after>`: the goal metric and the guards, before against after, from the runs
631
+ * in two folders. A run that was blocked, taken on a busy computer or profiled is left out (and counted).
632
+ */
633
+ export function perfCompare(beforeDir, afterDir, { goal = DEFAULT_GOAL, guards = null, also = [], min = 0.03, write = true } = {}) {
634
+ const A = readRuns(beforeDir);
635
+ const B = readRuns(afterDir);
636
+ if (!A.runs.length || !B.runs.length) return { ok: false, command: 'perf compare', why: `no runs in ${!A.runs.length ? beforeDir : afterDir} (folders that homie-studio perf wrote)` };
637
+ const usable = (r) => !r.blocked && !r.loaded;
638
+ const left = { before: A.runs.filter((r) => !usable(r)).length, after: B.runs.filter((r) => !usable(r)).length };
639
+ const flat = (runs, sizes) => {
640
+ const by = {};
641
+ for (const r of runs.filter(usable)) for (const [k, v] of Object.entries(metricsOfRun(r))) (by[k] ??= []).push(v);
642
+ for (const [k, v] of Object.entries(metricsOfSizes(sizes))) by[k] = [v];
643
+ return by;
644
+ };
645
+ const a = flat(A.runs, A.sizes);
646
+ const b = flat(B.runs, B.sizes);
647
+ // Runs taken side by side carry the same `pair` label on both sides (perf --pair; the perf skill's try): judge those
648
+ // pairs, so a computer that drifted busier hits both sides of each and cancels. The newest usable run of a label wins.
649
+ const byPair = (runs) => { const m = new Map(); for (const r of runs.filter(usable)) { const k = `${r.device}:${r.pair}`; if (r.pair && (!m.has(k) || String(r.at) > String(m.get(k).at))) m.set(k, r); } return m; };
650
+ const pa = byPair(A.runs);
651
+ const pb = byPair(B.runs);
652
+ const shared = [...pa.keys()].filter((k) => pb.has(k)).sort();
653
+ const paired = shared.length >= 3;
654
+ const pairedOf = (k) => {
655
+ const xs = []; const ys = [];
656
+ for (const id of shared) { const x = metricsOfRun(pa.get(id))[k]; const y = metricsOfRun(pb.get(id))[k]; if (Number.isFinite(x) && Number.isFinite(y)) { xs.push(x); ys.push(y); } }
657
+ return [xs, ys];
658
+ };
659
+ const devices = [...new Set([...A.runs, ...B.runs].map((r) => r.device))];
660
+ const unit = (k) => (k.startsWith('bytes.') ? ' B' : /\.load\.gameKb$/.test(k) ? ' KB' : /\.(frame|work)\.(p\d+|max|mean)$|\.busy$|\.load\./.test(k) ? ' ms' : /\.frame\.over\d+$/.test(k) ? '%' : /heap$/.test(k) ? ' MB' : /heap\.growth$/.test(k) ? ' MB/min' : /\.net\.kb/.test(k) ? ' KB/s' : /\.net\.msgs/.test(k) ? '/s' : /\.script$/.test(k) ? ' ms/s' : '');
661
+ const one = (k, opts = {}) => {
662
+ if (paired && !k.startsWith('bytes.')) { const [xs, ys] = pairedOf(k); if (xs.length >= 3) return { metric: k, ...judgePaired(xs, ys, { min, unit: unit(k), ...opts }) }; }
663
+ return { metric: k, ...judge(a[k] ?? [], b[k] ?? [], { min, unit: unit(k), ...opts }) };
664
+ };
665
+ const g = one(goal);
666
+ const enough = (k) => (a[k]?.length ?? 0) >= (k.startsWith('bytes.') ? 1 : 3) && (b[k]?.length ?? 0) >= (k.startsWith('bytes.') ? 1 : 3);
667
+ const guardList = [...new Set([...(guards ?? defaultGuards(devices, goal)), ...also])].filter((k) => k !== goal && a[k] && b[k]).map((k) => one(k));
668
+ const worse = guardList.filter((x) => x.verdict === 'worse');
669
+ let verdict;
670
+ let why;
671
+ if (!enough(goal)) { verdict = 'blocked'; why = `${goal}: too few runs that count (${a[goal]?.length ?? 0} before, ${b[goal]?.length ?? 0} after; at least 3 each). ${left.before + left.after} run(s) were left out (blocked or on a busy computer): measure again when the computer is calmer`; }
672
+ else if (g.verdict === 'worse') { verdict = 'worse'; why = `${goal} got worse: ${g.why}`; }
673
+ else if (worse.length) { verdict = 'worse'; why = `${worse.map((x) => `${x.metric} got worse: ${x.why}`).join('; ')}`; }
674
+ else if (g.verdict === 'better') { verdict = 'better'; why = `${goal} is better beyond the noise: ${g.why}`; }
675
+ else { verdict = 'same'; why = `${goal}: ${g.why}`; }
676
+ const result = { ok: true, command: 'perf compare', before: resolve(beforeDir), after: resolve(afterDir), goal: g, verdict, why, guards: guardList, left, min, test: paired ? `paired: ${shared.length} pairs run side by side (Wilcoxon signed-rank, exact)` : 'unpaired: every run against every run (Mann-Whitney U, exact)' };
677
+ if (write) writeFileSync(join(afterDir, 'compare.json'), `${JSON.stringify(result, null, 1)}\n`);
678
+ return { ...result, file: write ? join(afterDir, 'compare.json') : null };
679
+ }
680
+