Add a main-thread perf harness for the worker → client update pipeline (#4243)

## What

`npm run perf:client` — a headless harness (companion to `npm run
perf:game` from #4228) that measures the **main-thread burst** the
client runs every simulation tick. The sim ticks at 10Hz in a worker;
each tick the main thread synchronously runs deserialization →
`GameView.update()` → `WebGLFrameBuilder.update()` → HUD ticks. On
low-end devices that burst exceeds the 16.7ms frame budget and shows up
as a stutter every 100ms. Before optimizing that path, this gives us
numbers.

Per tick it runs the real pipeline end to end and times three stages:

- **clone** — `structuredClone` of the `GameUpdateViewData` with the
same transfer list `Worker.worker.ts` uses (serialize+deserialize, an
upper bound on the main-thread share of the real `postMessage`)
- **view** — the real client `GameView.update()`, including all
`populateFrame()` derivations
- **builder** — the real `WebGLFrameBuilder.update()` against a no-op GL
stub that counts payload sizes

It reports mean/p50/p95/p99/max per stage, slowest bursts with their
tile counts, payload stats, a filtered V8 CPU profile table, and writes
a `.cpuprofile`. Not covered (browser-only): CPU inside the WebGL view's
`update*()` methods and HUD layer ticks.

Same flags as `perf:game`: `--map --ticks --bots --nations --seed --top
--no-cpu-profile`.

## Determinism

- Prints the sim **Final hash**, which matches the `perf:game`
references on all three standard configs (world/200t/100b →
`5607618202213430`, default → `29309648281599524`, giantworldmap/600t →
`39945089450032050`) — the harness's worker side is faithful.
- Prints a **View hash** (FNV over the tile-state buffer, FrameData
deriveds, and per-player/unit view state) — verified stable across runs.
Client-side optimizations should keep it identical, the same workflow as
the sim hash.

## Baseline (this machine; low-end devices are ~5–20× slower)

Default run (world, 400 bots, 1800 ticks):

| stage | mean | p50 | p95 | p99 | max |
|---|---|---|---|---|---|
| clone (serialize+deserialize) | 1.02ms | 0.96 | 1.53 | 2.11 | 9.15 |
| GameView.update | 0.62ms | 0.58 | 0.93 | 1.25 | 5.09 |
| WebGLFrameBuilder.update | 0.04ms | 0.04 | 0.05 | 0.07 | 0.17 |
| **TOTAL burst** | **1.67ms** | **1.60** | **2.46** | **3.47** |
**10.3** |

giantworldmap/600t: TOTAL mean 2.54ms, p99 5.65ms, max 6.42ms.

Notable: the clone is the largest stage (~60%) — the packed tile/motion
buffers transfer for free, so the cost is structured-cloning the
`updates` object (~278 partial player updates/tick on world, ~508 on
giantworldmap). Inside `view`, the recurring cost is `populateFrame`'s
derivations (`computePlayerStatus`, the O(players²) relation matrix,
alliance clusters); tile apply dominates the land-grab spikes.

## Code changes outside the harness

- `WebGLFrameBuilder`: the `./render/gl` import is now `import type` so
the module loads under Node — a value import pulls `GPURenderer` and its
`.glsl?raw` shader imports. No behavior change (the symbols were only
used in type positions).
- `tests/perf/client/Shims.ts`: an in-memory `localStorage` shim so
`UserSettings`/theme code runs under Node (all settings resolve to
defaults, which is also the deterministic choice).

## Verification

- Sim + view hashes identical on repeat runs.
- `npm test` (1474 tests), `eslint`, `prettier --check`, `tsc --noEmit`
all pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-06-12 12:25:54 -07:00
committed by GitHub
parent 19db66f424
commit aa22339f96
4 changed files with 669 additions and 1 deletions
+1
View File
@@ -15,6 +15,7 @@
"test": "vitest run && vitest run tests/server",
"perf": "npx tsx tests/perf/run-all.ts",
"perf:game": "npx tsx tests/perf/fullgame/FullGamePerf.ts",
"perf:client": "npx tsx tests/perf/client/ClientUpdatePerf.ts",
"test:coverage": "vitest run --coverage",
"format": "prettier --ignore-unknown --write .",
"format:map-generator": "cd map-generator && go fmt .",
+3 -1
View File
@@ -5,7 +5,9 @@ import { decodePatternData } from "../core/PatternDecoder";
import { PlayerType } from "../core/game/Game";
import { GameView } from "../core/game/GameView";
import { uploadFrameData } from "./render/frame/Upload";
import {
// Type-only: a value import would pull GPURenderer and its `.glsl?raw` shader
// imports into any non-Vite consumer (e.g. the Node perf harness).
import type {
PlayerStatic,
SpawnCenter,
GameView as WebGLGameView,
+635
View File
@@ -0,0 +1,635 @@
/**
* Main-thread (client-side) performance harness for the worker → client
* update pipeline.
*
* The simulation runs at 10Hz in a Web Worker; each tick the main thread
* synchronously runs structured-clone deserialization, GameView.update()
* (tile apply + player/unit state + FrameData derivations), and
* WebGLFrameBuilder.update() (player sync + GPU upload dispatch). On low-end
* devices that burst blows the 16.7ms frame budget → a visible stutter every
* 100ms. This harness measures that burst headlessly:
*
* sim — GameRunner.executeNextTick() (worker-side, for context)
* clone — structuredClone of GameUpdateViewData with the same transfer
* list Worker.worker.ts uses. Serialize+deserialize both run
* here, so this is an upper bound on the main-thread share of
* the real postMessage cost.
* view — the real client GameView.update(gu), including populateFrame()
* builder — the real WebGLFrameBuilder.update() against a no-op GL stub.
* CPU work inside the WebGL view's update*() methods (instance
* buffer building etc.) is NOT included — that needs a browser
* profile.
*
* HUD layer ticks (GameRenderer.tick) are DOM-bound and not measured.
*
* Prints a deterministic "View hash" over the end-of-run FrameData/view state
* so client-side optimizations can be verified to not change view behavior
* (the analogue of the sim harness's "Final hash").
*
* Usage:
* npm run perf:client -- [--map world] [--ticks 1800] [--bots 400]
* [--seed perf-default] [--top 30] [--no-cpu-profile]
*/
import "./Shims"; // must be first: browser-global shims for client code
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { GameView } from "../../../src/client/view/GameView";
import { WebGLFrameBuilder } from "../../../src/client/WebGLFrameBuilder";
import { Config } from "../../../src/core/configuration/Config";
import {
Difficulty,
GameMapSize,
GameMapType,
GameMode,
GameType,
} from "../../../src/core/game/Game";
import {
GameUpdateType,
GameUpdateViewData,
HashUpdate,
} from "../../../src/core/game/GameUpdates";
import { loadTerrainMap } from "../../../src/core/game/TerrainMapLoader";
import { createGameRunner } from "../../../src/core/GameRunner";
import { GameConfig, GameStartInfo } from "../../../src/core/Schemas";
import type { WorkerClient } from "../../../src/core/worker/WorkerClient";
import { NodeGameMapLoader } from "../fullgame/NodeGameMapLoader";
import {
CpuProfiler,
summarizeCpuProfile,
TickStats,
} from "../fullgame/Profiler";
const PROJECT_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../..",
);
const MAX_SPAWN_TURNS = 1000;
/** One 60Hz frame. A tick burst above this drops at least one frame. */
const FRAME_BUDGET_MS = 16.7;
// ── CLI ──
interface Options {
map: GameMapType;
ticks: number;
bots: number;
nations: "default" | "disabled" | number;
seed: string;
top: number;
cpuProfile: boolean;
}
function resolveMap(name: string): GameMapType {
const key = Object.keys(GameMapType).find(
(k) => k.toLowerCase() === name.toLowerCase(),
);
if (key === undefined) {
const available = Object.keys(GameMapType)
.map((k) => k.toLowerCase())
.join(", ");
throw new Error(`unknown map "${name}". Available: ${available}`);
}
return GameMapType[key as keyof typeof GameMapType];
}
function parseArgs(argv: string[]): Options {
const opts: Options = {
map: GameMapType.World,
ticks: 1800,
bots: 400,
nations: "default",
seed: "perf-default",
top: 30,
cpuProfile: true,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const next = () => {
const v = argv[++i];
if (v === undefined) throw new Error(`missing value for ${arg}`);
return v;
};
switch (arg) {
case "--map":
opts.map = resolveMap(next());
break;
case "--ticks":
opts.ticks = parseInt(next(), 10);
break;
case "--bots":
opts.bots = parseInt(next(), 10);
break;
case "--nations": {
const v = next();
opts.nations =
v === "default" || v === "disabled" ? v : parseInt(v, 10);
break;
}
case "--seed":
opts.seed = next();
break;
case "--top":
opts.top = parseInt(next(), 10);
break;
case "--no-cpu-profile":
opts.cpuProfile = false;
break;
default:
throw new Error(`unknown argument: ${arg}`);
}
}
return opts;
}
// ── No-op GL view stub ──
//
// Counts calls and payload sizes so the report shows what the GPU layer
// would have received, without doing any work. Any WebGLFrameBuilder call to
// a method not stubbed here throws — that's intentional (fail loudly when
// the upload surface grows).
function createGlStub() {
const counts = new Map<string, number>();
let unitsSeen = 0;
let namesSeen = 0;
let changedTilesSeen = 0;
const bump = (name: string, by = 1) =>
counts.set(name, (counts.get(name) ?? 0) + by);
const noop = (name: string) => () => bump(name);
const view = {
// WebGLFrameBuilder syncs
addPlayers: (players: unknown[]) => bump("addPlayers", players.length),
updatePalette: noop("updatePalette"),
setPlayerSkin: noop("setPlayerSkin"),
setPlayerSpawn: noop("setPlayerSpawn"),
setLocalPlayerID: noop("setLocalPlayerID"),
setLocalRailColor: noop("setLocalRailColor"),
updateSpawnOverlay: noop("updateSpawnOverlay"),
initSkinAtlas: noop("initSkinAtlas"),
applyTerrainDelta: (refs: number[]) =>
bump("applyTerrainDelta", refs.length),
// uploadFrameData dispatch targets (FrameUploadTarget)
uploadTileAndTrailState: noop("uploadTileAndTrailState"),
uploadLiveDelta: (_: unknown, changed: unknown[]) => {
bump("uploadLiveDelta");
changedTilesSeen += changed.length;
},
uploadLiveTrailDelta: noop("uploadLiveTrailDelta"),
applyFullTiles: noop("applyFullTiles"),
applyDelta: noop("applyDelta"),
uploadRailroadState: noop("uploadRailroadState"),
applyRailroadDust: noop("applyRailroadDust"),
updateUnits: (units: ReadonlyMap<number, unknown>) => {
bump("updateUnits");
unitsSeen += units.size;
},
updateStructures: noop("updateStructures"),
applyDeadUnits: noop("applyDeadUnits"),
applyConquestEvents: noop("applyConquestEvents"),
applyBonusEvents: noop("applyBonusEvents"),
updateAttackRings: noop("updateAttackRings"),
updateNukeTelegraphs: noop("updateNukeTelegraphs"),
updateNames: (names: ReadonlyMap<string, unknown>) => {
bump("updateNames");
namesSeen += names.size;
},
updateRelations: noop("updateRelations"),
setSAMAllianceClusters: noop("setSAMAllianceClusters"),
};
return {
view,
counts,
stats: () => ({ unitsSeen, namesSeen, changedTilesSeen }),
};
}
// ── View-state hash (determinism check for client-side optimizations) ──
class Fnv32 {
private h = 0x811c9dc5;
mixByte(b: number): void {
this.h ^= b & 0xff;
this.h = Math.imul(this.h, 0x01000193) >>> 0;
}
mixU32(n: number): void {
this.mixByte(n);
this.mixByte(n >>> 8);
this.mixByte(n >>> 16);
this.mixByte(n >>> 24);
}
mixString(s: string): void {
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
this.mixByte(c);
this.mixByte(c >>> 8);
}
}
digest(): string {
return this.h.toString(16).padStart(8, "0");
}
}
const jsonReplacer = (_key: string, value: unknown) =>
typeof value === "bigint" ? `${value}n` : value;
/**
* Deterministic hash over the renderer-facing view state: the tile texture
* buffer plus FrameData's derived structures and per-player/per-unit state.
* Map iteration order is insertion order, which is deterministic given a
* deterministic simulation.
*/
function computeViewHash(gameView: GameView): string {
const fnv = new Fnv32();
const frame = gameView.frameData();
const tileState = gameView.tileStateBuffer();
for (let i = 0; i < tileState.length; i++) {
fnv.mixU32(tileState[i]);
}
fnv.mixU32(frame.relationSize);
for (let i = 0; i < frame.relationMatrix.length; i++) {
fnv.mixByte(frame.relationMatrix[i]);
}
for (const [id, entry] of frame.names) {
fnv.mixString(id);
fnv.mixU32(entry.x);
fnv.mixU32(entry.y);
fnv.mixU32(entry.size);
}
for (const [sid, status] of frame.playerStatus) {
fnv.mixU32(sid);
fnv.mixString(JSON.stringify(status, jsonReplacer));
}
for (const [sid, cluster] of frame.allianceClusters) {
fnv.mixU32(sid);
fnv.mixU32(cluster);
}
for (const state of gameView.unitStates().values()) {
fnv.mixString(JSON.stringify(state, jsonReplacer));
}
for (const state of gameView.playerStates().values()) {
fnv.mixString(JSON.stringify(state, jsonReplacer));
}
return fnv.digest();
}
// ── Report formatting ──
function fmtMs(ms: number): string {
return ms >= 100 ? ms.toFixed(0) : ms >= 10 ? ms.toFixed(1) : ms.toFixed(2);
}
function table(headers: string[], rows: string[][]): string {
const widths = headers.map((h, c) =>
Math.max(h.length, ...rows.map((r) => r[c].length)),
);
const line = (cells: string[]) =>
cells.map((cell, c) => cell.padEnd(widths[c])).join(" ");
return [line(headers), line(widths.map((w) => "-".repeat(w)))]
.concat(rows.map(line))
.join("\n");
}
// ── Main ──
async function main(): Promise<void> {
const opts = parseArgs(process.argv.slice(2));
console.debug = () => {}; // silence per-tick debug logging
const gameConfig: GameConfig = {
gameMap: opts.map,
gameMapSize: GameMapSize.Normal,
gameMode: GameMode.FFA,
gameType: GameType.Public,
difficulty: Difficulty.Medium,
nations: opts.nations,
donateGold: false,
donateTroops: false,
bots: opts.bots,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
randomSpawn: false,
};
const gameStart: GameStartInfo = {
gameID: opts.seed,
lobbyCreatedAt: 0,
config: gameConfig,
players: [],
};
console.log(
`Loading map "${opts.map}" (bots=${opts.bots}, nations=${opts.nations}, ` +
`seed=${opts.seed}, ticks=${opts.ticks})...`,
);
const mapLoader = new NodeGameMapLoader(
path.join(PROJECT_ROOT, "resources/maps"),
);
// Worker side: the exact pipeline Worker.worker.ts runs.
let currentGu: GameUpdateViewData | null = null;
let lastHash: HashUpdate | undefined;
let fatalError: string | undefined;
const runner = await createGameRunner(
gameStart,
undefined,
mapLoader,
(gu) => {
if ("errMsg" in gu) {
fatalError = `${gu.errMsg}\n${gu.stack ?? ""}`;
return;
}
currentGu = gu;
},
);
// Client side: own Config + own map load, mirroring createClientGame (the
// real client and worker each load their own copy of the map).
const clientConfig = new Config(gameConfig, null, false);
const clientMapData = await loadTerrainMap(
gameConfig.gameMap,
gameConfig.gameMapSize,
mapLoader,
);
const gameView = new GameView(
{} as unknown as WorkerClient, // only stored; async query methods unused here
clientConfig,
clientMapData,
undefined, // no local client — bots-only game, myPlayer stays null
"perf-harness",
null,
gameStart.gameID,
gameStart.players,
);
const glStub = createGlStub();
const builder = new WebGLFrameBuilder(
glStub.view as unknown as ConstructorParameters<
typeof WebGLFrameBuilder
>[0],
);
// Per-stage stats. "total" is clone+view+builder — the main-thread burst.
const stats = {
sim: new TickStats(),
clone: new TickStats(),
view: new TickStats(),
builder: new TickStats(),
total: new TickStats(),
};
const tilePairsByTick = new Map<number, number>();
let totalTilePairs = 0;
let maxTilePairs = 0;
let unitUpdatesTotal = 0;
let playerUpdatesTotal = 0;
let turnNumber = 0;
const runTick = (recordInto: typeof stats): boolean => {
runner.addTurn({ turnNumber: turnNumber++, intents: [] });
currentGu = null;
let start = performance.now();
const ok = runner.executeNextTick();
const simMs = performance.now() - start;
if (!ok || fatalError !== undefined || currentGu === null) {
return false;
}
const gu: GameUpdateViewData = currentGu;
const tick = gu.tick;
recordInto.sim.record(tick, simMs);
const hashes = gu.updates[GameUpdateType.Hash] as HashUpdate[];
if (hashes.length > 0) {
lastHash = hashes[hashes.length - 1];
}
const pairs = gu.packedTileUpdates.length / 2;
tilePairsByTick.set(tick, pairs);
totalTilePairs += pairs;
maxTilePairs = Math.max(maxTilePairs, pairs);
unitUpdatesTotal += gu.updates[GameUpdateType.Unit].length;
playerUpdatesTotal += gu.updates[GameUpdateType.Player].length;
// Same transfer list as Worker.worker.ts sendGameUpdateBatch().
const transfers: Transferable[] = [gu.packedTileUpdates.buffer];
if (gu.packedMotionPlans) {
transfers.push(gu.packedMotionPlans.buffer);
}
start = performance.now();
const cloned = structuredClone(gu, { transfer: transfers });
const cloneMs = performance.now() - start;
recordInto.clone.record(tick, cloneMs);
// Same call chain as the ClientGameRunner worker.start() callback.
start = performance.now();
gameView.update(cloned);
const viewMs = performance.now() - start;
recordInto.view.record(tick, viewMs);
start = performance.now();
builder.update(gameView);
const builderMs = performance.now() - start;
recordInto.builder.record(tick, builderMs);
recordInto.total.record(tick, cloneMs + viewMs + builderMs);
return true;
};
// Spawn phase (full pipeline, reported separately).
const spawnStats = {
sim: new TickStats(),
clone: new TickStats(),
view: new TickStats(),
builder: new TickStats(),
total: new TickStats(),
};
const spawnStart = performance.now();
while (runner.game.inSpawnPhase()) {
if (turnNumber >= MAX_SPAWN_TURNS) {
throw new Error(`spawn phase did not end after ${MAX_SPAWN_TURNS} turns`);
}
if (!runTick(spawnStats)) {
throw new Error(`game errored during spawn phase:\n${fatalError}`);
}
}
const spawnTurns = turnNumber;
const spawnClientMs = spawnStats.total.summarize(FRAME_BUDGET_MS).totalMs;
console.log(
`Spawn phase done: ${spawnTurns} turns in ` +
`${fmtMs(performance.now() - spawnStart)}ms wall ` +
`(${fmtMs(spawnClientMs)}ms client-side), ` +
`${runner.game.players().filter((p) => p.isAlive()).length} players spawned.`,
);
// Main game phase, under the CPU profiler.
const cpuProfiler = opts.cpuProfile ? new CpuProfiler() : null;
if (cpuProfiler) {
await cpuProfiler.start();
}
const gamePhaseStart = performance.now();
let heapPeak = 0;
for (let i = 0; i < opts.ticks; i++) {
if (!runTick(stats)) {
console.error(
`game errored at tick ${runner.game.ticks()}:\n${fatalError}`,
);
process.exitCode = 1;
break;
}
if (i % 50 === 0) {
heapPeak = Math.max(heapPeak, process.memoryUsage().heapUsed);
}
}
const gamePhaseMs = performance.now() - gamePhaseStart;
const profile = cpuProfiler ? await cpuProfiler.stop() : null;
// ── Report ──
const summaries = {
sim: stats.sim.summarize(FRAME_BUDGET_MS),
clone: stats.clone.summarize(FRAME_BUDGET_MS),
view: stats.view.summarize(FRAME_BUDGET_MS),
builder: stats.builder.summarize(FRAME_BUDGET_MS),
total: stats.total.summarize(FRAME_BUDGET_MS),
};
const n = summaries.total.count;
console.log(`\n${"=".repeat(72)}`);
console.log(`Client update perf: ${opts.map}, ${n} game ticks`);
console.log("=".repeat(72));
console.log(`\n--- Game state at end ---`);
console.log(`Ticks executed: ${runner.game.ticks()} (${spawnTurns} spawn)`);
console.log(
`Players alive: ${runner.game.players().filter((p) => p.isAlive()).length}` +
` / ${runner.game.players().length}`,
);
console.log(`View units: ${gameView.units().length}`);
console.log(
`Sim final hash: ${lastHash ? `${lastHash.hash} (tick ${lastHash.tick})` : "n/a"}`,
);
console.log(`View hash: ${computeViewHash(gameView)}`);
console.log(`Peak heap: ${(heapPeak / 1024 / 1024).toFixed(0)} MB`);
console.log(
`\n--- Main-thread cost per tick (game phase, ${fmtMs(gamePhaseMs)}ms wall) ---`,
);
const stageRows: [string, (typeof summaries)["total"]][] = [
["clone (serialize+deserialize)", summaries.clone],
["GameView.update", summaries.view],
["WebGLFrameBuilder.update", summaries.builder],
["TOTAL main-thread burst", summaries.total],
["(sim tick, worker-side)", summaries.sim],
];
console.log(
table(
[
"stage",
"mean",
"p50",
"p95",
"p99",
"max",
"total ms",
`>${FRAME_BUDGET_MS}ms`,
],
stageRows.map(([name, s]) => [
name,
fmtMs(s.meanMs),
fmtMs(s.p50Ms),
fmtMs(s.p95Ms),
fmtMs(s.p99Ms),
fmtMs(s.maxMs),
fmtMs(s.totalMs),
`${s.overBudget} / ${s.count}`,
]),
),
);
console.log(
`\nSlowest bursts: ` +
summaries.total.slowest
.map(
(s) =>
`#${s.tick} (${fmtMs(s.ms)}ms, ${tilePairsByTick.get(s.tick) ?? 0} tiles)`,
)
.join(", "),
);
console.log(`\n--- Update payload (game phase) ---`);
const glStats = glStub.stats();
console.log(
`Tile updates: ${totalTilePairs} pairs total, ` +
`mean ${(totalTilePairs / Math.max(1, n)).toFixed(0)}/tick, max ${maxTilePairs}/tick`,
);
console.log(
`Unit updates: ${unitUpdatesTotal} total, ` +
`mean ${(unitUpdatesTotal / Math.max(1, n)).toFixed(1)}/tick`,
);
console.log(
`Player updates: ${playerUpdatesTotal} total, ` +
`mean ${(playerUpdatesTotal / Math.max(1, n)).toFixed(1)}/tick`,
);
console.log(
`GPU dispatch: updateUnits saw ${glStats.unitsSeen} unit-entries, ` +
`updateNames saw ${glStats.namesSeen} name-entries, ` +
`uploadLiveDelta saw ${glStats.changedTilesSeen} tiles ` +
`(all across whole run; per-entry CPU cost not measured — see header)`,
);
if (profile) {
console.log(
`\n--- Top client-side functions by self time (V8 sampling profiler) ---`,
);
console.log(
`(%, of the whole game phase including the sim — client work is the` +
` clone/view/builder share above)`,
);
const fns = summarizeCpuProfile(profile, PROJECT_ROOT).filter(
(f) =>
f.location.startsWith("src/client") ||
f.functionName.includes("structuredClone") ||
f.functionName.includes("deserialize") ||
f.functionName.includes("serialize"),
);
console.log(
table(
["self ms", "%", "function", "location"],
fns
.slice(0, opts.top)
.map((f) => [
fmtMs(f.selfMs),
f.selfPct.toFixed(1),
f.functionName,
f.location,
]),
),
);
const outDir = path.join(PROJECT_ROOT, "tests/perf/output");
fs.mkdirSync(outDir, { recursive: true });
const outFile = path.join(
outDir,
`client-${opts.map.replace(/\W+/g, "_")}-${opts.seed}.cpuprofile`,
);
fs.writeFileSync(outFile, JSON.stringify(profile));
console.log(
`\nCPU profile written to ${path.relative(PROJECT_ROOT, outFile)}` +
` (open in Chrome DevTools > Performance; sim frames included — ` +
`filter by src/client)`,
);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+30
View File
@@ -0,0 +1,30 @@
/**
* Browser-global shims for running client code (src/client/view, theme,
* WebGLFrameBuilder) under Node. Import this FIRST — ESM executes imports in
* order, so it must precede any module that touches these globals.
*
* UserSettings reads localStorage lazily; an in-memory store means every
* setting resolves to its default, which is also the deterministic choice.
*/
if (typeof globalThis.localStorage === "undefined") {
const store = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => store.clear(),
key: (i: number) => [...store.keys()][i] ?? null,
get length() {
return store.size;
},
},
});
}
export {};