@homie-rocks/studio 0.18.2 → 0.19.1

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/README.md CHANGED
@@ -15,6 +15,7 @@ cd night-owls && npm install
15
15
  npx homie-studio game new crown-thief --from gem-rush --name "Crown Thief"
16
16
  npx homie-studio dev # the whole site locally
17
17
  npx homie-studio check crown-thief --url http://127.0.0.1:8787
18
+ npx homie-studio perf crown-thief --url http://127.0.0.1:8787 # how fast it runs: a computer and an emulated phone, host and replica
18
19
  npx homie-studio deploy --plan # what deploy will create, and what it costs; changes nothing
19
20
  npx homie-studio deploy # the studio's own Cloudflare
20
21
  npx homie-studio publish # the homie.rocks directory
@@ -409,6 +410,42 @@ feed every command behaves exactly as before. `lib/progress.mjs` has the whole f
409
410
  names the Cloudflare account, often after its owner. A custom domain goes in studio.json
410
411
  as `cloudflare.domain` and is what the directory claim, `publish`, `check` and `stats` use.
411
412
 
413
+ ## How fast it runs: `homie-studio perf`
414
+
415
+ ```sh
416
+ npx homie-studio perf crown-thief --url http://127.0.0.1:8787 [--device computer,phone] [--runs 3] [--profile]
417
+ npx homie-studio perf sizes crown-thief # every built file, raw and gzipped; with build --maps, the bundle's modules
418
+ npx homie-studio perf compare .perf/crown-thief/<before> .perf/crown-thief/<after> --goal phone.host.frame.p95
419
+ ```
420
+
421
+ One run is two headless Chromes on this computer's GPU in a fresh room of their own (`?room=perf-…`): the host (the
422
+ rules, the bots, the snapshots) and a replica, both a computer (1280x800 at 2x) or both an emulated phone (390x844 at
423
+ 3x, touch, Chrome's CPU throttle at 4x with `--cpu`, 4G; each run records the slow-down the throttle really gave, which
424
+ on a fast computer is less than its rate). They play the same seeded presses through a warm-up and a measured
425
+ window, and each run's JSON says, per browser: the time between animation frames (median, p95, p99, the share over 33
426
+ and 50 ms), the game's JavaScript per frame (every requestAnimationFrame callback timed), the main thread per frame
427
+ (Chrome's TaskDuration), time to the first frame, to a seat and to playable, the game's files on the wire, the heap
428
+ after a garbage collection, and netplay messages and kilobytes a second each way. `--profile` adds a CPU profile per
429
+ browser in a window of its own (a `.cpuprofile` for Chrome DevTools) and its hottest functions, read through the
430
+ game's source map when the build kept one (`homie-studio build --maps` keeps it in `.studio/maps/<id>/`, never in
431
+ `site/dist`).
432
+
433
+ The computer is shared, so every run waits for the 1-minute load to fall under 0.8 per core (`--max-load`) and
434
+ records the load before and after; a run that started busy says `loaded` and `compare` leaves it out. A software
435
+ renderer (SwiftShader) makes the run `blocked`: nothing on it is judged. `compare` calls a metric better only when a
436
+ rank test says it is unlikely to be chance (p < 0.05), the 95% bootstrap interval of the change stays below zero, and
437
+ it is at least 3% better; runs taken side by side with the same `--pair` label are judged as pairs (a signed-rank
438
+ test on their ratios), so a computer that drifted busier cancels. Guards (frame time, main thread per frame, time to
439
+ playable, heap, the host's upload) must not get worse. Results are files under `.perf/<id>/` (git-ignored); the
440
+ command prints paths and medians. The Homie plugin's `perf` skill runs the whole loop: baseline, one change at a time,
441
+ keep or revert, and a report in the studio's `perf/` folder.
442
+
443
+ `perf sizes` reads each big script for whether it is minified from its code, not from how well it gzips: whitespace,
444
+ comments and names outside its strings (a minifier leaves about 1 to 2% whitespace; indented source has 15 to 30%).
445
+ Minified JavaScript gzips about as well as source text, and a bundle with three.js in it carries the shaders as GLSL
446
+ source in strings, which no minifier touches; the line for such a file says `minified, 19% GLSL shader source in
447
+ strings`, and the JSON's `code` has every measure.
448
+
412
449
  ## Checks on a computer without a GPU
413
450
 
414
451
  `check`, `port check` and `look` run Chrome headless. On a Mac they use its GPU. On Linux (a Claude Code cloud
@@ -16,10 +16,27 @@
16
16
  * homie-studio game new <id> [--from gem-rush] [--name "<Game Name>"]
17
17
  * homie-studio game remix <source.json url> --id <new id>
18
18
  * homie-studio games
19
- * homie-studio build [<id>]
19
+ * homie-studio build [<id>] [--maps] (--maps: also keep each bundle's source map and module sizes in .studio/maps/<id>/,
20
+ * never in site/dist: what `perf` reads a CPU profile through)
20
21
  * homie-studio dev [--port 8787] [--remote-ai] (--stop: stop exactly this studio's dev server, nothing else;
21
22
  * AI guides think scripted here unless --remote-ai: real Workers AI, billed)
22
23
  * homie-studio check <id> [--url <site>] [--shots <dir>]
24
+ * homie-studio perf <id> [--url <site>] [--device computer,phone] [--runs 1] [--seconds 15] [--warm 3] [--profile]
25
+ * [--cpu 4] [--out <dir>] [--max-load 0.8] [--pair <label>]
26
+ * (how fast it runs: per device, two browsers in a fresh room, the host and a replica,
27
+ * playing: frame times (median, p95, p99, long frames), the game's JavaScript per frame,
28
+ * the main thread per frame, time to playable and what it downloaded, the heap, netplay
29
+ * messages a second; the computer's load with every run. --profile: a CPU profile of
30
+ * each browser and its hottest functions. Files under .perf/<id>/<time>/; the phone is
31
+ * emulated (a --cpu times slower CPU, 4G) on this computer's GPU. The plugin's perf
32
+ * skill runs the whole measure, change, compare, keep-or-revert loop)
33
+ * homie-studio perf sizes <id> (what a player downloads: every built file, raw and gzipped, the biggest first; with
34
+ * build --maps, which modules make up the bundle; each big script read for whether it
35
+ * is minified, from its code, and how much of it is GLSL shader source in strings)
36
+ * homie-studio perf compare <before dir> <after dir> [--goal phone.host.frame.p95] [--min 3] [--guards a,b] [--also c,d]
37
+ * (better, worse or the same within the noise: medians, a 95% interval, a rank test,
38
+ * and the guards that must not get worse; runs taken side by side with the same
39
+ * --pair label on both sides are judged as pairs, so a computer's drift cancels)
23
40
  * homie-studio chrome [install] [--fresh] (which Chrome the checks use; on Linux, `install` fetches Chrome for Testing;
24
41
  * --fresh fetches it even when the machine has a Chrome)
25
42
  * homie-studio look [<path>...] [--url <site>] [--shots <dir>] [--only computer,phone,sideways]
@@ -149,6 +166,7 @@ import { publish } from '../lib/directory.mjs';
149
166
  import { look } from '../lib/look.mjs';
150
167
  import { importPort, planPort } from '../lib/port.mjs';
151
168
  import { portCheck } from '../lib/port-check.mjs';
169
+ import { DEFAULT_GOAL, perfCompare, perfRun, perfSizes } from '../lib/perf.mjs';
152
170
  import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeOf, typeOf } from '../lib/media.mjs';
153
171
  import { ensureMigrations, migrationWord, newStudio } from '../lib/scaffold.mjs';
154
172
  import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
@@ -170,7 +188,7 @@ import { formatHandoff, handoff } from '../lib/handoff.mjs';
170
188
 
171
189
  const argv = process.argv.slice(2);
172
190
  const flags = new Map();
173
- const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify'];
191
+ const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile'];
174
192
  const positional = [];
175
193
  for (let i = 0; i < argv.length; i++) {
176
194
  const a = argv[i];
@@ -451,6 +469,26 @@ function print(result) {
451
469
  case 'chrome install':
452
470
  lines.push(result.already || result.command === 'chrome' ? `Chrome: ${result.chrome}` : `Installed Chrome for Testing ${result.buildId}: ${result.chrome}`);
453
471
  break;
472
+ case 'perf':
473
+ lines.push(`Measured ${result.game}: ${result.runs.length} run(s), in ${result.out}`, ...result.headline.map((l) => ` ${l}`),
474
+ ...(result.blocked ? [` ${result.blocked}`] : []), ...(result.loaded ? [` ${result.loaded}`] : []),
475
+ `Each run: ${result.out}/<device>-<n>.json (and its screenshots); medians: ${result.summary}${result.sizes ? `; sizes: ${result.sizes}` : ''}`,
476
+ ...(result.profiles?.length ? [`CPU profiles (Chrome DevTools opens them; the hottest functions are in each run's JSON): ${result.profiles.join(', ')}`] : []));
477
+ break;
478
+ case 'perf sizes': {
479
+ const kb = (b) => `${(b / 1024).toFixed(1)} KB`;
480
+ lines.push(`${result.game}: ${result.total.files} files, ${kb(result.total.bytes)} (${kb(result.total.gzip)} gzipped); JavaScript ${kb(result.js.bytes)} (${kb(result.js.gzip)} gzipped)`,
481
+ 'Biggest:', ...result.biggest.slice(0, 8).map((f) => ` ${kb(f.bytes).padStart(10)} ${f.path}${f.gzip !== f.bytes ? ` (${kb(f.gzip)} gzipped)` : ''}${f.code ? ` ${f.code.minified ? `minified${f.code.shaderPct >= 5 ? `, ${f.code.shaderPct}% GLSL shader source in strings` : ''}` : 'NOT minified'}` : ''}`),
482
+ ...(result.modules ? ['Bundle modules:', ...result.modules.top.slice(0, 8).map((m) => ` ${kb(m.bytes).padStart(10)} ${m.module}`)] : []),
483
+ ...(result.apart ? [`Not loaded by the game: ${result.apart.files} file(s), ${kb(result.apart.bytes)} (${result.apart.note})`] : []));
484
+ break;
485
+ }
486
+ case 'perf compare':
487
+ lines.push(`${result.verdict.toUpperCase()}: ${result.why}`,
488
+ ...result.guards.filter((g) => g.verdict !== 'same').map((g) => ` ${g.verdict} ${g.metric}: ${g.why}`),
489
+ ...(result.left.before + result.left.after ? [` left out: ${result.left.before} run(s) before, ${result.left.after} after (blocked or started on a busy computer)`] : []),
490
+ `Written: ${result.file}`);
491
+ break;
454
492
  case 'check':
455
493
  lines.push(`PASS: two fresh browsers in room ${result.room} finished round ${result.round.n} (${result.round.humans} humans, ${result.round.bots} bots) in ${Math.round(result.totalMs / 1000)} s.`,
456
494
  ...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`),
@@ -490,6 +528,10 @@ async function main() {
490
528
  if (line) process.stdout.write(`${line}\n`);
491
529
  return { ok: true, command: 'help' };
492
530
  }
531
+ if (cmd === 'perf' && sub === 'compare') {
532
+ if (!positional[2] || !positional[3]) return { ok: false, command: 'perf compare', why: 'usage: homie-studio perf compare <before dir> <after dir> [--goal <metric>] [--min 3]' };
533
+ return perfCompare(positional[2], positional[3], { goal: String(flags.get('goal') ?? DEFAULT_GOAL), min: flags.has('min') ? Number(flags.get('min')) / 100 : 0.03, guards: flags.get('guards') ? String(flags.get('guards')).split(',').map((x) => x.trim()).filter(Boolean) : null, also: flags.get('also') ? String(flags.get('also')).split(',').map((x) => x.trim()).filter(Boolean) : [] });
534
+ }
493
535
  const root = requireStudio();
494
536
  if (cmd === 'statusline') return installStatusLine(root, { remove: flags.has('remove'), replace: flags.has('replace'), project: flags.get('project') ?? null });
495
537
  if (cmd === 'codex') return codexCommand(root, sub);
@@ -506,7 +548,16 @@ async function main() {
506
548
  if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
507
549
  if (cmd === 'game' && sub === 'remix') return remixGame(root, positional[2], flags.get('id'), { name: flags.get('name') });
508
550
  if (cmd === 'games') return { ok: true, command: 'games', games: listGames(root).map(({ dir, ...g }) => ({ ...g, dir: relative(root, dir) })) };
509
- if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log }), 'build');
551
+ if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log, maps: flags.has('maps') }), 'build');
552
+ if (cmd === 'perf' && sub === 'sizes') return perfSizes(root, positional[2] ?? listGames(root)[0]?.id);
553
+ if (cmd === 'perf') {
554
+ const game = sub ?? listGames(root)[0]?.id;
555
+ const url = flags.get('url') ?? siteUrl(root);
556
+ if (!url) return { ok: false, command: 'perf', why: 'give --url (the local dev address or the live site)' };
557
+ const devices = String(flags.get('device') ?? 'computer,phone').split(',').map((d) => d.trim()).filter(Boolean);
558
+ const num = (k, d, lo, hi) => Math.max(lo, Math.min(hi, Number(flags.get(k) ?? d) || d));
559
+ return perfRun({ root, url, game, devices, runs: num('runs', 1, 1, 50), seconds: num('seconds', 15, 3, 300), warm: num('warm', 3, 0, 60), profile: flags.has('profile'), out: flags.get('out') ? resolve(String(flags.get('out'))) : null, maxLoad: num('max-load', 0.8, 0.05, 50), cpu: num('cpu', 4, 1, 20), pair: flags.get('pair') ? String(flags.get('pair')) : null, log });
560
+ }
510
561
  if (cmd === 'status') {
511
562
  const studio = readStudio(root);
512
563
  return { ok: true, command: 'status', root, studio, site: siteUrl(root, studio), games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
@@ -869,7 +920,7 @@ try {
869
920
  if (result && result.command !== 'help') print(result);
870
921
  if (result?.ok === false) process.exitCode = 1;
871
922
  // Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
872
- if (result?.command === 'check' || result?.command === 'port check' || result?.command === 'look') process.exit(process.exitCode ?? 0);
923
+ if (['check', 'port check', 'look', 'perf'].includes(result?.command)) process.exit(process.exitCode ?? 0);
873
924
  } catch (error) {
874
925
  await flushProgress().catch(() => {});
875
926
  print({ ok: false, why: error instanceof Error ? error.message : String(error) });
package/lib/build.mjs CHANGED
@@ -30,6 +30,7 @@
30
30
  * the game's own build (Vite, webpack…), then its output copied; base must be './'
31
31
  */
32
32
  import { spawnSync } from 'node:child_process';
33
+ import { createHash } from 'node:crypto';
33
34
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
34
35
  import { createRequire } from 'node:module';
35
36
  import { join, relative } from 'node:path';
@@ -205,7 +206,23 @@ export function seatsFor(g, net = netplayOf(g)) {
205
206
  return { min: Math.min(max, minNamed ?? 1), max, asked };
206
207
  }
207
208
 
208
- export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1' } = {}) {
209
+ /**
210
+ * `build --maps` (what `homie-studio perf` reads a CPU profile through): the bundle's source map and esbuild's metafile
211
+ * go to .studio/maps/<id>/ (git-ignored, this computer's own), never into site/dist, so a deploy never ships them. The
212
+ * bundle itself is byte for byte the one a plain build makes (an external map adds no comment to it); main.js.sha256
213
+ * says which bundle the map belongs to, so a map left over from an older build is never used.
214
+ */
215
+ function keepMap(root, id, out, metafile) {
216
+ const dir = join(root, '.studio', 'maps', id);
217
+ rmSync(dir, { recursive: true, force: true });
218
+ mkdirSync(dir, { recursive: true });
219
+ const map = join(out, 'assets', 'main.js.map');
220
+ if (existsSync(map)) { cpSync(map, join(dir, 'main.js.map')); rmSync(map, { force: true }); }
221
+ writeFileSync(join(dir, 'meta.json'), JSON.stringify(metafile ?? {}));
222
+ writeFileSync(join(dir, 'main.js.sha256'), `${createHash('sha256').update(readFileSync(join(out, 'assets', 'main.js'))).digest('hex')}\n`);
223
+ }
224
+
225
+ export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false } = {}) {
209
226
  const require = createRequire(join(root, 'package.json'));
210
227
  let esbuild;
211
228
  try { esbuild = require('esbuild'); } catch { esbuild = await import('esbuild'); }
@@ -228,7 +245,7 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
228
245
  const entry = join(g.dir, entryRel);
229
246
  if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${entryRel} not found`);
230
247
  const result = await esbuild.build({
231
- entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: false,
248
+ entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: maps ? 'external' : false,
232
249
  outfile: join(out, 'assets', 'main.js'), absWorkingDir: root, logLevel: 'silent', metafile: true,
233
250
  loader: LOADERS, assetNames: '[name]-[hash]',
234
251
  }).catch((error) => {
@@ -236,6 +253,7 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
236
253
  throw new Error(`games/${g.id} did not build: ${first ? `${first.text}${first.location ? ` (${first.location.file}:${first.location.line})` : ''}` : error.message}`);
237
254
  });
238
255
  warnings += result.warnings.length;
256
+ if (maps) keepMap(root, g.id, out, result.metafile);
239
257
  };
240
258
  if (mode === 'static') {
241
259
  copyStatic(g.dir, out);
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Is a JavaScript file minified, and how much of it is strings and GLSL shader source? Read from the code itself,
3
+ * never from how well the whole file gzips.
4
+ *
5
+ * How well a file compresses says nothing about it: minified JavaScript gzips to a quarter or a third of its bytes
6
+ * (three.core.min.js 26%, three.module.min.js 24%), more than its indented source does (20%: whitespace is the easiest
7
+ * thing to compress). And a bundle with three.js in it carries the shaders as GLSL source in strings, line by line, with
8
+ * their indentation and their long names, which no minifier touches: a game's minified three.js bundle can be thousands
9
+ * of lines, nearly all of them inside shader strings.
10
+ *
11
+ * So the file is read the way a JavaScript parser reads it, just far enough to tell code from strings, template
12
+ * literals, regular expressions and comments, and only the code is judged:
13
+ * - whitespace: the share of the code's characters that are spaces, tabs and line breaks. A minifier leaves the
14
+ * few a keyword needs (about 1 to 2%); indented source is 15 to 30%;
15
+ * - comments: the share of the file in comments, licence comments (`/*!`, `@license`, `@preserve`) apart, since a
16
+ * minifier keeps those;
17
+ * - lines: the code's characters per line (string contents not counted);
18
+ * - names: the identifiers that are not a property (after `.`) or an object's key, which a minifier renames: their
19
+ * mean length and the share one or two characters long (`mangled`). A minifier that keeps names (whitespace and
20
+ * syntax only) is still a minifier.
21
+ * And the strings: the share of the file inside string and template literals, and the share of those that are GLSL
22
+ * shader source (#ifdef, uniform vec3, gl_FragColor, texture2D( ...).
23
+ *
24
+ * It is a reader, not a parser: a `/` after `)` or `}` is division unless the `(` followed if/while/for/with or the
25
+ * `{` opened a block, and a regular expression never spans a line (when it seems to, it was division). That is what a
26
+ * minifier's output and ordinary source need; a file it cannot read cleanly is judged on what it read.
27
+ */
28
+
29
+ const KEYWORDS = new Set(['break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'export', 'extends', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'return', 'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield', 'await', 'of', 'null', 'true', 'false', 'static', 'async', 'get', 'set', 'enum', 'implements', 'interface', 'package', 'private', 'protected', 'public', 'undefined', 'arguments', 'eval']);
30
+ /** After these a `/` starts a regular expression; after any other keyword (this, null, true ...) it is division. */
31
+ const REGEX_AFTER_KEYWORD = new Set(['return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'throw', 'case', 'do', 'else', 'yield', 'await', 'extends']);
32
+ /** A `{` after one of these opens a block (a `/` after its `}` starts a statement); anywhere else, an object. */
33
+ const BLOCK_AFTER_PUNCT = new Set([')', ';', '{', '}', '=>']);
34
+ const BLOCK_AFTER_KEYWORD = new Set(['else', 'do', 'try', 'finally']);
35
+
36
+ /** GLSL in a string: one of these marks a shader line; a literal with two different ones (or a long one with one) is shader source. */
37
+ const GLSL = [
38
+ /#\s*(?:ifdef|ifndef|endif|elif|define|undef|include\s*<|pragma|extension|version)\b/,
39
+ /\b(?:uniform|varying|attribute)\s+(?:(?:lowp|mediump|highp)\s+)?(?:float|int|uint|bool|[biu]?vec[234]|mat[234](?:x[234])?|sampler\w*)\b/,
40
+ /\bprecision\s+(?:lowp|mediump|highp)\s+(?:float|int|sampler\w*)\b/,
41
+ /\bgl_(?:Position|FragColor|FragCoord|FragData|PointSize|PointCoord|InstanceID|VertexID|FrontFacing)\b/,
42
+ /\bvoid\s+main\s*\(/,
43
+ /\b(?:texture2D|textureCube|texture2DLodEXT|textureLod|texelFetch)\s*\(/,
44
+ /\b(?:[biu]?vec[234]|mat[234])\s+\w+\s*[=;,)]/,
45
+ /\b(?:[biu]?vec[234]|mat[234])\s*\(/,
46
+ /\b(?:smoothstep|inversesqrt|fract|clamp|saturate|dot|normalize|mix)\s*\([^)]*\)\s*;/,
47
+ ];
48
+ function glslScore(text) {
49
+ // A shader says what it is in its first lines; a huge literal (a base64 blob) is read only that far.
50
+ const head = text.length > 65_536 ? text.slice(0, 65_536) : text;
51
+ let n = 0;
52
+ for (const re of GLSL) if (re.test(head)) n++;
53
+ return n;
54
+ }
55
+ const isShader = (text) => { const s = glslScore(text); return s >= 2 || (s >= 1 && text.length >= 400); };
56
+
57
+ const isIdStart = (c) => (c >= 97 && c <= 122) || (c >= 65 && c <= 90) || c === 36 || c === 95 || c >= 0x80 || c === 92;
58
+ const isIdPart = (c) => isIdStart(c) || (c >= 48 && c <= 57);
59
+ const isDigit = (c) => c >= 48 && c <= 57;
60
+ const isWs = (c) => c === 32 || c === 9 || c === 10 || c === 13 || c === 11 || c === 12 || c === 0xa0 || c === 0xfeff || c === 0x2028 || c === 0x2029;
61
+
62
+ /**
63
+ * Read `text` (a JavaScript file). Returns the measures above, rounded, and the verdict:
64
+ * { minified, mangled, whitespacePct, commentPct, licencePct, codeCharsPerLine, nameLength, shortNamesPct, names,
65
+ * stringPct, shaderPct, bytes, why }
66
+ */
67
+ export function readCode(text) {
68
+ const src = String(text);
69
+ const n = src.length;
70
+ let i = 0;
71
+ let codeChars = 0; // characters read as code (not in a string, template, comment or regular expression's body)
72
+ let codeWs = 0;
73
+ let codeBreaks = 0;
74
+ let commentChars = 0;
75
+ let licenceChars = 0;
76
+ let stringChars = 0;
77
+ let shaderChars = 0;
78
+ const names = { count: 0, short: 0, chars: 0 };
79
+ /** The last significant token: { t: 'name'|'kw'|'num'|'str'|'regex'|'punct', v } */
80
+ let prev = null;
81
+ const braces = []; // '{b' a block, '{o' an object or other expression, '${' a template's substitution
82
+ const parens = []; // true when the ( followed if/while/for/with
83
+ const templates = []; // the text of each open template literal (one may hold ${ } with another inside)
84
+
85
+ const literal = (body) => {
86
+ stringChars += body.length;
87
+ if (body.length >= 12 && isShader(body)) shaderChars += body.length;
88
+ };
89
+
90
+ const regexAllowed = () => {
91
+ if (!prev) return true;
92
+ if (prev.t === 'name' || prev.t === 'num' || prev.t === 'str' || prev.t === 'regex') return false;
93
+ if (prev.t === 'kw') return REGEX_AFTER_KEYWORD.has(prev.v);
94
+ if (prev.v === ')' || prev.v === ']' || prev.v === '}') return false;
95
+ return true;
96
+ };
97
+
98
+ /** Scan a template literal's text from i up to its closing backtick (done) or a `${` (back to code). */
99
+ const templateText = () => {
100
+ const start = i;
101
+ while (i < n) {
102
+ const c = src.charCodeAt(i);
103
+ if (c === 92) { i += 2; continue; }
104
+ if (c === 96) { templates[templates.length - 1] += src.slice(start, i); i++; literal(templates.pop()); prev = { t: 'str' }; return; }
105
+ if (c === 36 && src.charCodeAt(i + 1) === 123) { templates[templates.length - 1] += src.slice(start, i); i += 2; braces.push('${'); prev = { t: 'punct', v: '${' }; return; }
106
+ i++;
107
+ }
108
+ templates[templates.length - 1] += src.slice(start, n);
109
+ literal(templates.pop());
110
+ };
111
+
112
+ while (i < n) {
113
+ const c = src.charCodeAt(i);
114
+ if (isWs(c)) {
115
+ codeChars++; codeWs++;
116
+ if (c === 10) codeBreaks++;
117
+ i++;
118
+ continue;
119
+ }
120
+ // Comments.
121
+ if (c === 47 && src.charCodeAt(i + 1) === 47) {
122
+ const end = src.indexOf('\n', i);
123
+ const stop = end < 0 ? n : end;
124
+ commentChars += stop - i;
125
+ i = stop;
126
+ continue;
127
+ }
128
+ if (c === 47 && src.charCodeAt(i + 1) === 42) {
129
+ const end = src.indexOf('*/', i + 2);
130
+ const stop = end < 0 ? n : end + 2;
131
+ const body = src.slice(i, stop);
132
+ if (body.startsWith('/*!') || /@license|@preserve|@copyright/i.test(body)) licenceChars += stop - i; else commentChars += stop - i;
133
+ i = stop;
134
+ continue;
135
+ }
136
+ // Strings.
137
+ if (c === 39 || c === 34) {
138
+ const start = i + 1;
139
+ let j = start;
140
+ while (j < n) {
141
+ const d = src.charCodeAt(j);
142
+ if (d === 92) { j += 2; continue; }
143
+ if (d === c || d === 10) break;
144
+ j++;
145
+ }
146
+ literal(src.slice(start, j));
147
+ i = Math.min(n, j + 1);
148
+ prev = { t: 'str' };
149
+ continue;
150
+ }
151
+ if (c === 96) { i++; templates.push(''); templateText(); continue; }
152
+ // Regular expressions (only where an expression can start, and never across a line).
153
+ if (c === 47 && regexAllowed()) {
154
+ let j = i + 1;
155
+ let inClass = false;
156
+ let ok = false;
157
+ while (j < n) {
158
+ const d = src.charCodeAt(j);
159
+ if (d === 10 || d === 13) break;
160
+ if (d === 92) { j += 2; continue; }
161
+ if (d === 91) inClass = true;
162
+ else if (d === 93) inClass = false;
163
+ else if (d === 47 && !inClass) { ok = true; break; }
164
+ j++;
165
+ }
166
+ if (ok) {
167
+ j++;
168
+ while (j < n && isIdPart(src.charCodeAt(j))) j++;
169
+ codeChars += j - i;
170
+ i = j;
171
+ prev = { t: 'regex' };
172
+ continue;
173
+ }
174
+ }
175
+ // Names and keywords.
176
+ if (isIdStart(c)) {
177
+ let j = i + 1;
178
+ while (j < n && isIdPart(src.charCodeAt(j))) j++;
179
+ const word = src.slice(i, j);
180
+ codeChars += j - i;
181
+ const afterDot = prev && prev.t === 'punct' && prev.v === '.';
182
+ if (!afterDot && KEYWORDS.has(word)) prev = { t: 'kw', v: word };
183
+ else {
184
+ // A property (after `.`) and an object's key (`{a:` or `,a:`) keep their names in any minifier: not counted.
185
+ let k = j;
186
+ while (k < n && isWs(src.charCodeAt(k))) k++;
187
+ const key = prev && prev.t === 'punct' && (prev.v === '{' || prev.v === ',') && src.charCodeAt(k) === 58 && braces[braces.length - 1] === '{o';
188
+ if (!afterDot && !key) {
189
+ names.count++;
190
+ names.chars += word.length;
191
+ if (word.length <= 2) names.short++;
192
+ }
193
+ prev = { t: 'name', v: word };
194
+ }
195
+ i = j;
196
+ continue;
197
+ }
198
+ // Numbers.
199
+ if (isDigit(c) || (c === 46 && isDigit(src.charCodeAt(i + 1)))) {
200
+ let j = i + 1;
201
+ while (j < n) {
202
+ const d = src.charCodeAt(j);
203
+ if (isIdPart(d) || d === 46) { j++; continue; }
204
+ if ((d === 43 || d === 45) && /[eE]/.test(src[j - 1]) && !/^0[xX]/.test(src.slice(i, j))) { j++; continue; }
205
+ break;
206
+ }
207
+ codeChars += j - i;
208
+ i = j;
209
+ prev = { t: 'num' };
210
+ continue;
211
+ }
212
+ // Punctuation.
213
+ codeChars++;
214
+ if (c === 123) { // {
215
+ const block = !prev || (prev.t === 'punct' && BLOCK_AFTER_PUNCT.has(prev.v)) || (prev.t === 'kw' && BLOCK_AFTER_KEYWORD.has(prev.v));
216
+ braces.push(block ? '{b' : '{o');
217
+ prev = { t: 'punct', v: '{' };
218
+ i++;
219
+ continue;
220
+ }
221
+ if (c === 125) { // }
222
+ const open = braces.pop();
223
+ i++;
224
+ if (open === '${') { templateText(); continue; }
225
+ // After a block a statement starts (a `/` there is a regular expression); after an object it is division.
226
+ prev = { t: 'punct', v: open === '{b' ? ';' : '}' };
227
+ continue;
228
+ }
229
+ if (c === 40) { parens.push(Boolean(prev && prev.t === 'kw' && ['if', 'while', 'for', 'with'].includes(prev.v))); prev = { t: 'punct', v: '(' }; i++; continue; }
230
+ if (c === 41) { const cond = parens.pop(); prev = { t: 'punct', v: cond ? ';' : ')' }; i++; continue; }
231
+ if (c === 61 && src.charCodeAt(i + 1) === 62) { codeChars++; prev = { t: 'punct', v: '=>' }; i += 2; continue; }
232
+ if (c === 46 && src.charCodeAt(i + 1) === 46 && src.charCodeAt(i + 2) === 46) { codeChars += 2; prev = { t: 'punct', v: '...' }; i += 3; continue; }
233
+ prev = { t: 'punct', v: src[i] };
234
+ i++;
235
+ }
236
+
237
+ const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : 0);
238
+ const whitespacePct = pct(codeWs, codeChars);
239
+ const commentPct = pct(commentChars, n);
240
+ const licencePct = pct(licenceChars, n);
241
+ const codeCharsPerLine = Math.round((codeChars - codeWs) / (codeBreaks + 1));
242
+ const nameLength = names.count ? Math.round((names.chars / names.count) * 10) / 10 : null;
243
+ const shortNamesPct = pct(names.short, names.count);
244
+ const stringPct = pct(stringChars, n);
245
+ const shaderPct = pct(shaderChars, n);
246
+ // Minified: the code carries almost no whitespace, and almost no comments but its licence. Mangled: most names
247
+ // one or two characters. (Indented source: 15-30% whitespace; esbuild's or terser's output: 1-2%.)
248
+ const minified = codeChars > 0 && whitespacePct < 6 && commentPct < 2;
249
+ const mangled = names.count >= 20 && shortNamesPct >= 70;
250
+ const said = `whitespace ${whitespacePct}% of the code, comments ${commentPct}%, ${codeCharsPerLine} characters a line of code, names ${nameLength ?? '-'} characters on average (${shortNamesPct}% one or two)`;
251
+ const why = minified
252
+ ? `minified${mangled ? ', names shortened' : ', names kept'} (${said})${stringPct >= 15 ? `; ${stringPct}% of its bytes are strings${shaderPct >= 5 ? `, ${shaderPct}% GLSL shader source` : ''}` : ''}`
253
+ : `not minified (${said})`;
254
+ return { minified, mangled, whitespacePct, commentPct, licencePct, codeCharsPerLine, nameLength, shortNamesPct, names: names.count, stringPct, shaderPct, bytes: n, why };
255
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * WHERE A GAME'S TIME GOES: a Chrome CPU profile (`homie-studio perf --profile`, the .cpuprofile DevTools opens)
3
+ * summed per function, and the game's minified bundle read back through its source map, so the summary names
4
+ * `draw (src/main.ts:620)` rather than `Xe (main.js:1:48213)`.
5
+ *
6
+ * Self time is the time a function itself was on the stack's top (what to make cheaper); total time includes what it
7
+ * called (where a frame's time is spent overall). Idle, the garbage collector and Chrome's own work ("(program)") are
8
+ * kept apart, so a game that is mostly idle reads as idle, not as a list of tiny hot spots.
9
+ */
10
+
11
+ const B64 = new Map([...'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'].map((c, i) => [c, i]));
12
+
13
+ /** A source map's `mappings` as one array of segments per generated line: [column, source, line, column, name?]. */
14
+ export function decodeMappings(mappings) {
15
+ const lines = [];
16
+ let src = 0;
17
+ let line = 0;
18
+ let col = 0;
19
+ let name = 0;
20
+ for (const text of String(mappings ?? '').split(';')) {
21
+ const segs = [];
22
+ let gen = 0;
23
+ for (const seg of text.split(',')) {
24
+ if (!seg) continue;
25
+ const v = [];
26
+ let value = 0;
27
+ let shift = 0;
28
+ for (const ch of seg) {
29
+ const d = B64.get(ch);
30
+ if (d === undefined) break;
31
+ value += (d & 31) << shift;
32
+ if (d & 32) { shift += 5; continue; }
33
+ v.push(value & 1 ? -(value >>> 1) : value >>> 1);
34
+ value = 0;
35
+ shift = 0;
36
+ }
37
+ gen += v[0] ?? 0;
38
+ if (v.length >= 4) {
39
+ src += v[1]; line += v[2]; col += v[3];
40
+ if (v.length >= 5) { name += v[4]; segs.push([gen, src, line, col, name]); } else segs.push([gen, src, line, col]);
41
+ }
42
+ }
43
+ lines.push(segs);
44
+ }
45
+ return lines;
46
+ }
47
+
48
+ /**
49
+ * A lookup for one source map: (0-based line, column) in the generated file → { source, line (1-based), column, name },
50
+ * the nearest mapping at or before that column on that line; null outside the map.
51
+ */
52
+ export function sourceMapLookup(map) {
53
+ const lines = decodeMappings(map?.mappings);
54
+ const sources = (map?.sources ?? []).map((s) => String(s).replace(/^(\.\.\/)+/, '').replace(/^webpack:\/\/\//, ''));
55
+ const names = map?.names ?? [];
56
+ const content = map?.sourcesContent ?? [];
57
+ return (line, column) => {
58
+ const segs = lines[line];
59
+ if (!segs?.length) return null;
60
+ let lo = 0;
61
+ let hi = segs.length - 1;
62
+ let best = -1;
63
+ while (lo <= hi) { const mid = (lo + hi) >> 1; if (segs[mid][0] <= column) { best = mid; lo = mid + 1; } else hi = mid - 1; }
64
+ if (best < 0) return null;
65
+ // A function's own position often lands on its name token; prefer a named segment within a few columns.
66
+ let seg = segs[best];
67
+ for (let k = best; k >= 0 && column - segs[k][0] <= 12; k--) if (segs[k][4] !== undefined) { seg = segs[k]; break; }
68
+ const source = sources[seg[1]] ?? null;
69
+ const named = seg[4] !== undefined ? names[seg[4]] ?? null : null;
70
+ const text = typeof content[seg[1]] === 'string' ? content[seg[1]].split('\n')[seg[2]] ?? '' : '';
71
+ // What the line defines wins over a segment's name: at an arrow function V8 points at its first parameter, whose
72
+ // segment names the parameter, not the function.
73
+ return { source, line: seg[2] + 1, column: seg[3], name: nameFromLine(text, seg[3]) ?? named };
74
+ };
75
+ }
76
+
77
+ /**
78
+ * The function a line of source defines, when the line says: `function draw(`, `const step = (`, `draw(…) {` (a method),
79
+ * `step: (dt) =>`. The whole line first (a line usually defines one function), then from the mapped column on.
80
+ */
81
+ function nameFromLine(text, column) {
82
+ const tries = [
83
+ /function\s*\*?\s*([A-Za-z_$][\w$]*)\s*[(<]/,
84
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*(?::[^=]+)?=>|[A-Za-z_$][\w$]*\s*=>)/,
85
+ /^\s*(?:(?:public|private|protected|static|async|get|set)\s+)*([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?::[^{]+)?\{/,
86
+ /([A-Za-z_$][\w$]*)\s*:\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*(?::[^=]+)?=>)/,
87
+ ];
88
+ for (const re of tries) { const m = re.exec(text); if (m && !['if', 'for', 'while', 'switch', 'catch', 'return'].includes(m[1])) return m[1]; }
89
+ void column;
90
+ return null;
91
+ }
92
+
93
+ const SPECIAL = new Set(['(root)', '(program)', '(idle)', '(garbage collector)']);
94
+
95
+ /**
96
+ * A CPU profile summed per function. `maps`: [{ match: (url) => boolean, lookup }] (sourceMapLookup), for scripts
97
+ * shipped minified. `game`: (url) => boolean, the game's own scripts (the rest is the play page and the browser).
98
+ * Returns milliseconds and shares of the busy (not idle) time, the hottest functions first.
99
+ */
100
+ export function summarizeProfile(profile, { maps = [], game = () => true, top = 15 } = {}) {
101
+ const nodes = new Map((profile?.nodes ?? []).map((n) => [n.id, n]));
102
+ const parent = new Map();
103
+ for (const n of nodes.values()) for (const c of n.children ?? []) parent.set(c, n.id);
104
+ const samples = profile?.samples ?? [];
105
+ const deltas = profile?.timeDeltas ?? [];
106
+ // Each sample stands for the time until the next one (the last: the average step).
107
+ const span = (i) => (i + 1 < deltas.length ? deltas[i + 1] : (deltas.length ? deltas.reduce((s, x) => s + x, 0) / deltas.length : 0));
108
+ const keyOf = (cf) => `${cf.functionName || '(anonymous)'}|${cf.url}|${cf.lineNumber}|${cf.columnNumber}`;
109
+ const fns = new Map();
110
+ const fnOf = (n) => {
111
+ const cf = n.callFrame;
112
+ const k = keyOf(cf);
113
+ let f = fns.get(k);
114
+ if (!f) { f = { key: k, cf, self: 0, total: 0 }; fns.set(k, f); }
115
+ return f;
116
+ };
117
+ let all = 0;
118
+ let idle = 0;
119
+ let gc = 0;
120
+ let program = 0;
121
+ const scripts = new Map();
122
+ for (let i = 0; i < samples.length; i++) {
123
+ const n = nodes.get(samples[i]);
124
+ if (!n) continue;
125
+ const us = span(i);
126
+ all += us;
127
+ const name = n.callFrame.functionName;
128
+ if (name === '(idle)') { idle += us; continue; }
129
+ if (name === '(garbage collector)') gc += us;
130
+ if (name === '(program)') program += us;
131
+ fnOf(n).self += us;
132
+ const script = n.callFrame.url ? scriptName(n.callFrame.url) : SPECIAL.has(name) ? name : '(native)';
133
+ scripts.set(script, (scripts.get(script) ?? 0) + us);
134
+ // Total: once per function per sample, however deep the recursion.
135
+ const seen = new Set();
136
+ for (let id = n.id; id !== undefined; id = parent.get(id)) {
137
+ const p = nodes.get(id);
138
+ if (!p || p.callFrame.functionName === '(root)') break;
139
+ const f = fnOf(p);
140
+ if (seen.has(f)) continue;
141
+ seen.add(f);
142
+ f.total += us;
143
+ }
144
+ }
145
+ const busy = all - idle;
146
+ const ms = (us) => Math.round(us / 100) / 10;
147
+ const pct = (us) => (busy ? Math.round((us / busy) * 1000) / 10 : 0);
148
+ const place = (cf) => {
149
+ if (!cf.url) return null;
150
+ const map = maps.find((m) => m.match(cf.url));
151
+ const hit = map ? map.lookup(cf.lineNumber, cf.columnNumber) : null;
152
+ if (hit?.source) return { name: hit.name ?? null, where: `${hit.source}:${hit.line}`, mapped: true };
153
+ return { name: null, where: `${scriptName(cf.url)}:${cf.lineNumber + 1}:${cf.columnNumber + 1}`, mapped: false };
154
+ };
155
+ const rows = [...fns.values()].filter((f) => !SPECIAL.has(f.cf.functionName)).sort((a, b) => b.self - a.self).slice(0, top).map((f) => {
156
+ const at = place(f.cf);
157
+ const minified = f.cf.functionName || '(anonymous)';
158
+ // V8 names a function from where it was declared or assigned; one it calls anonymous (a callback) stays so, and
159
+ // `where` says which line it is on.
160
+ return { name: f.cf.functionName ? at?.name ?? minified : '(anonymous)', ...(at?.mapped && at.name && at.name !== minified ? { minified } : {}), where: at?.where ?? '(native)', game: Boolean(f.cf.url && game(f.cf.url)), selfMs: ms(f.self), selfPct: pct(f.self), totalPct: pct(f.total) };
161
+ });
162
+ const byScript = [...scripts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([script, us]) => ({ script, ms: ms(us), pct: pct(us) }));
163
+ let gameUs = 0;
164
+ for (const f of fns.values()) if (f.cf.url && game(f.cf.url)) gameUs += f.self;
165
+ return { ms: ms(all), busyMs: ms(busy), idlePct: all ? Math.round((idle / all) * 1000) / 10 : 0, gcPct: pct(gc), programPct: pct(program), gamePct: pct(gameUs), top: rows, scripts: byScript };
166
+ }
167
+
168
+ /** A script's address without the site: `/gem-rush/__game/assets/main.js`, `/_homie/site.js`. */
169
+ export function scriptName(url) {
170
+ try { const u = new URL(url); return u.pathname || url; } catch { return String(url).slice(0, 120); }
171
+ }