perf(client): main-thread memory harness + drop three map-sized render buffers (-23%) (#4511)

## Summary

Follow-up to #4507, moving the memory-footprint campaign to the **main
thread** (client). Two parts: a headless browser measurement harness,
and a first optimization round that cuts the main-thread live heap on
Giant World Map by **23%** (166 → 128 MB at tick 2000).

## Part 1 — `npm run perf:client-mem`: headless main-thread memory
harness

Drives a real singleplayer game in headless Chromium and measures the
**page's isolate only** (the core sim worker is a separate CDP target):

- Starts its own vite dev server on a private port (default 9017) so it
always measures the current checkout.
- Double-forced-GC checkpoints every `--window` ticks: JS heap,
ArrayBuffer backing-store bytes (`Runtime.getHeapUsage`), DOM nodes,
listeners, ticks/s.
- `--snapshot-at <ticks>` writes V8 heap snapshots, analyzable with the
retainer/summary tools from #4507.
- Spoofs the unmasked WebGL renderer string via an init script so the
software-GL gate (#4324) admits SwiftShader — no game code touched;
rendering still runs software (hence the rAF throttle).
- End-of-run screenshot as a rendering sanity check.

Baseline (Giant World Map, 400 bots, 12,000 ticks): ~176 MB live, of
which ~116 MB is **static per-tile buffers** allocated up front for the
8M-tile map — flat during play, no leaks.

## Part 2 — drop three map-sized render-layer buffer copies

| Buffer | Before | After |
|---|---|---|
| `TrailPass.cpuTrailState` | 15.3 MB copy | **deleted** — dead code;
every upload entry point sets the live reference to TrailManager's array
|
| `RailroadPass.cpuRailroadState` | 15.3 MB across 2 arrays | references
`RailroadCache.railroadState` (stable identity, mutated in place) |
| `RailroadPass.cpuGhostRailState` | ↑ | sparse `Map<ref, value>`;
preview diffs applied as per-texel `texSubImage2D` writes (path-sized
work instead of a full 8 MB texture upload per build-preview mouse move)
|
| `TerrainPass` + `MapRenderer` terrain bytes | 7.6 MB (one buffer, two
retainers) | `terrainSource()` provider — re-bakes (theme change,
context restore) regenerate from the live game map, which already
reflects water-nuke conversions |

Tick-2000 snapshot comparison (giant world, 400 bots): **166.4 → 128.4
MB**.

## Verification

- `tsc --noEmit`, eslint, full test suite (1924 tests) pass.
- 2000-tick headless giant-world game after the change: no GL
pageerrors, end-of-run screenshot renders
terrain/territory/borders/names correctly, sim speed unchanged (~5
ticks/s headless).
- Ghost-rail ops flush before the zoom-fade early-return, so the op
queue can't grow while previewing at low zoom.
- WebGL context restore recreates all passes fresh and the owner
re-uploads state (existing `onContextRestored` path), consistent with
the new reference-based buffers.

Note: heap snapshots in `tests/perf/output/` are gitignored; the numbers
above are from runs recorded in the PR discussion.

🤖 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-07-04 20:18:52 -07:00
committed by GitHub
parent 36aa38bcd0
commit 571f58440d
10 changed files with 551 additions and 67 deletions
+3 -2
View File
@@ -23,7 +23,8 @@ export const BASE_URL = process.env.OPENFRONT_URL ?? "http://localhost:9000";
// of CPU per frame, and an unthrottled rAF loop starves the
// main thread (timers, the singleplayer turn loop, input).
// ~1000 is a good value; frames still render for screenshots.
export async function launch({ viewport, rafIntervalMs } = {}) {
// args - extra Chromium flags appended to the defaults.
export async function launch({ viewport, rafIntervalMs, args } = {}) {
const env = { ...process.env };
const libs = path.join(CACHE, "extracted", "usr", "lib", "x86_64-linux-gnu");
if (fs.existsSync(libs)) {
@@ -33,7 +34,7 @@ export async function launch({ viewport, rafIntervalMs } = {}) {
env.FONTCONFIG_FILE = path.join(CACHE, "fonts.conf");
}
const browser = await chromium.launch({
args: ["--no-sandbox", "--disable-gpu"],
args: ["--no-sandbox", "--disable-gpu", ...(args ?? [])],
env,
});
const context = await browser.newContext({
+1
View File
@@ -16,6 +16,7 @@
"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",
"perf:client-mem": "npx tsx tests/perf/client/ClientMemoryPerf.ts",
"test:coverage": "vitest run --coverage",
"format": "prettier --ignore-unknown --write .",
"format:map-generator": "cd map-generator && go fmt .",
+14 -6
View File
@@ -275,12 +275,20 @@ function createWebGLView(
const mapWidth = gameMap.width();
const mapHeight = gameMap.height();
const terrainBytes = new Uint8Array(mapWidth * mapHeight);
for (let y = 0; y < mapHeight; y++) {
for (let x = 0; x < mapWidth; x++) {
terrainBytes[y * mapWidth + x] = gameMap.terrainByte(gameMap.ref(x, y));
// Provider, not a buffer: per-tile terrain bytes are map-sized (8 MB on
// the giant map), so consumers regenerate them on demand (initial bake,
// context restore, theme change) instead of anyone retaining a copy.
// gameMap is updated live by water-nuke conversions, so a regenerated
// array always reflects them.
const terrainSource = (): Uint8Array => {
const terrainBytes = new Uint8Array(mapWidth * mapHeight);
for (let y = 0; y < mapHeight; y++) {
for (let x = 0; x < mapWidth; x++) {
terrainBytes[y * mapWidth + x] = gameMap.terrainByte(gameMap.ref(x, y));
}
}
}
return terrainBytes;
};
const glCanvas = createCanvas();
glCanvas.id = "webgl-debug-canvas";
@@ -324,7 +332,7 @@ function createWebGLView(
// bound at construction time.
maxPlayers: 1024,
},
terrainBytes,
terrainSource,
palette,
config,
settings,
+5 -2
View File
@@ -47,7 +47,10 @@ export class MapRenderer {
constructor(
private canvas: HTMLCanvasElement,
private header: RendererConfig,
private terrainBytes: Uint8Array,
// Called (not stored) whenever terrain bytes are needed — initial bake
// and every context restore. Regenerating on demand avoids retaining a
// map-sized buffer for the rare restore path.
private terrainSource: () => Uint8Array,
private paletteData: Float32Array,
private config: Config,
// Resolved render settings (defaults + overrides). Held so the same object
@@ -79,7 +82,7 @@ export class MapRenderer {
this.renderer = new GPURenderer(
this.canvas,
this.header,
this.terrainBytes,
this.terrainSource,
this.paletteData,
this.config,
this.settings,
+20 -8
View File
@@ -197,7 +197,7 @@ export class GPURenderer {
constructor(
canvas: HTMLCanvasElement,
header: RendererConfig,
terrainBytes: Uint8Array,
terrainSource: () => Uint8Array,
paletteData: Float32Array,
config: Config,
settings: RenderSettings,
@@ -246,13 +246,25 @@ export class GPURenderer {
this.camera = new Camera(mapW, mapH);
// --- Terrain (static) ---
this.terrainPass = new TerrainPass(gl, terrainBytes, mapW, mapH, {
oceanColor: hexToRgb(this.settings.terrain.oceanColor) ?? undefined,
sandColor: hexToRgb(this.settings.terrain.sandColor) ?? undefined,
plainsColor: hexToRgb(this.settings.terrain.plainsColor) ?? undefined,
highlandColor: hexToRgb(this.settings.terrain.highlandColor) ?? undefined,
mountainColor: hexToRgb(this.settings.terrain.mountainColor) ?? undefined,
});
// Bake once and let the array go — nothing below retains map-sized
// terrain bytes; re-bakes call terrainSource again.
const terrainBytes = terrainSource();
this.terrainPass = new TerrainPass(
gl,
terrainSource,
terrainBytes,
mapW,
mapH,
{
oceanColor: hexToRgb(this.settings.terrain.oceanColor) ?? undefined,
sandColor: hexToRgb(this.settings.terrain.sandColor) ?? undefined,
plainsColor: hexToRgb(this.settings.terrain.plainsColor) ?? undefined,
highlandColor:
hexToRgb(this.settings.terrain.highlandColor) ?? undefined,
mountainColor:
hexToRgb(this.settings.terrain.mountainColor) ?? undefined,
},
);
// --- Shared palette texture (RGBA32F, 4096×2) ---
this.paletteData = paletteData;
+71 -34
View File
@@ -108,11 +108,23 @@ export class RailroadPass {
private mapH: number;
private settings: RenderSettings;
private cpuRailroadState: Uint8Array;
/**
* Reference to the caller-owned railroad state (RailroadCache's array;
* stable identity, mutated in place). The pass keeps no copy — the array
* must stay current until the flush. Null until the first upload.
*/
private liveRailroadRef: Uint8Array | null = null;
private railroadDirty = false;
private cpuGhostRailState: Uint8Array;
private ghostRailDirty = false;
/**
* Current ghost overlay content, sparse: tile ref → texel value (1-6 =
* orientation, 7 = overlap highlight). Ghost paths cover at most a few
* thousand tiles, so tracking them beats a full-map array + full-map
* texture upload per preview change.
*/
private ghostTiles = new Map<number, number>();
/** Pending ghost texel writes, interleaved [ref, value, …]. */
private ghostOps: number[] = [];
private ghostOwnerID = 0;
private localPlayerID = 0;
@@ -132,8 +144,6 @@ export class RailroadPass {
this.tileTex = tileTex;
this.paletteTex = paletteTex;
this.settings = settings;
this.cpuRailroadState = new Uint8Array(mapW * mapH);
this.cpuGhostRailState = new Uint8Array(mapW * mapH);
this.program = createProgram(
gl,
@@ -187,14 +197,14 @@ export class RailroadPass {
filter: gl.NEAREST,
});
// R8UI railroad texture
// R8UI railroad texture (null data = zero-initialized per the WebGL spec)
this.railroadTex = createTexture2D(gl, {
width: mapW,
height: mapH,
internalFormat: gl.R8UI,
format: gl.RED_INTEGER,
type: gl.UNSIGNED_BYTE,
data: this.cpuRailroadState,
data: null,
filter: gl.NEAREST,
});
@@ -205,7 +215,7 @@ export class RailroadPass {
internalFormat: gl.R8UI,
format: gl.RED_INTEGER,
type: gl.UNSIGNED_BYTE,
data: this.cpuGhostRailState,
data: null,
filter: gl.NEAREST,
});
@@ -213,7 +223,7 @@ export class RailroadPass {
}
uploadRailroadState(railroadState: Uint8Array): void {
this.cpuRailroadState.set(railroadState);
this.liveRailroadRef = railroadState;
this.railroadDirty = true;
}
@@ -257,7 +267,7 @@ export class RailroadPass {
}
updateGhostPreview(data: GhostPreviewData | null): void {
this.cpuGhostRailState.fill(0);
const next = new Map<number, number>();
if (data) {
const maxRef = this.mapW * this.mapH;
@@ -268,7 +278,7 @@ export class RailroadPass {
const tiles = this.computePathOrientations(path);
for (const t of tiles) {
if (t.ref >= 0 && t.ref < maxRef) {
this.cpuGhostRailState[t.ref] = t.type + 1;
next.set(t.ref, t.type + 1);
}
}
}
@@ -277,7 +287,7 @@ export class RailroadPass {
// overlappingRailroads contains resolved tile refs (not rail IDs)
for (const ref of data.overlappingRailroads) {
if (ref >= 0 && ref < maxRef) {
this.cpuGhostRailState[ref] = 7;
next.set(ref, 7);
}
}
@@ -286,7 +296,15 @@ export class RailroadPass {
this.ghostOwnerID = 0;
}
this.ghostRailDirty = true;
// Queue texel writes for the diff: clear tiles that left the ghost,
// (re)write tiles whose value is new or changed.
for (const ref of this.ghostTiles.keys()) {
if (!next.has(ref)) this.ghostOps.push(ref, 0);
}
for (const [ref, value] of next) {
if (this.ghostTiles.get(ref) !== value) this.ghostOps.push(ref, value);
}
this.ghostTiles = next;
}
/** Draw the railroad overlay. Must be called with alpha blending enabled. */
@@ -294,6 +312,10 @@ export class RailroadPass {
const gl = this.gl;
const rs = this.settings.railroad;
// Flush queued ghost texel writes even when faded out, so the op queue
// can't grow unboundedly while the player previews at low zoom.
this.flushGhostOps();
// Fade out as zoom drops below railMinZoom; fully invisible at railMinZoom - railFadeRange
const fadeRange = Math.max(rs.railFadeRange, 0);
const fadeStart = rs.railMinZoom - fadeRange;
@@ -305,8 +327,8 @@ export class RailroadPass {
: Math.min(1, Math.max(0, (zoom - fadeStart) / fadeRange));
if (fade <= 0) return;
// Flush CPU railroad state → GPU
if (this.railroadDirty) {
// Flush railroad state → GPU
if (this.railroadDirty && this.liveRailroadRef !== null) {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.railroadTex);
gl.texSubImage2D(
@@ -318,29 +340,11 @@ export class RailroadPass {
this.mapH,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
this.cpuRailroadState,
this.liveRailroadRef,
);
this.railroadDirty = false;
}
// Flush ghost railroad state → GPU
if (this.ghostRailDirty) {
gl.activeTexture(gl.TEXTURE4);
gl.bindTexture(gl.TEXTURE_2D, this.ghostRailTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
this.mapW,
this.mapH,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
this.cpuGhostRailState,
);
this.ghostRailDirty = false;
}
gl.useProgram(this.program);
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
@@ -378,6 +382,39 @@ export class RailroadPass {
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
/**
* Apply queued ghost texel writes to the ghost texture, one texel per
* texSubImage2D. Ghost diffs are path-sized (at most a few thousand
* texels), far cheaper than the full-map upload a dense mirror needs.
*/
private flushGhostOps(): void {
const ops = this.ghostOps;
if (ops.length === 0) return;
const gl = this.gl;
gl.activeTexture(gl.TEXTURE4);
gl.bindTexture(gl.TEXTURE_2D, this.ghostRailTex);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
const scratch = new Uint8Array(1);
for (let i = 0; i < ops.length; i += 2) {
const ref = ops[i];
const x = ref % this.mapW;
const y = (ref - x) / this.mapW;
scratch[0] = ops[i + 1];
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
x,
y,
1,
1,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
scratch,
);
}
ops.length = 0;
}
// ---- Rail orientation computation ----
private computePathOrientations(
+14 -7
View File
@@ -40,7 +40,12 @@ export class TerrainPass {
constructor(
private gl: WebGL2RenderingContext,
private terrainBytes: Uint8Array,
// Regenerates current per-tile terrain bytes (reflecting water-nuke
// conversions) for the rare full re-bake in setTerrainColors. A provider
// instead of a retained buffer: terrain bytes are map-sized (8 MB on the
// giant map).
private terrainSource: () => Uint8Array,
terrainBytes: Uint8Array,
mapW: number,
mapH: number,
terrainColors?: TerrainColorOverrides,
@@ -86,7 +91,12 @@ export class TerrainPass {
this.mapH,
gl.RGBA,
gl.UNSIGNED_BYTE,
buildTerrainRGBA(this.terrainBytes, this.mapW, this.mapH, terrainColors),
buildTerrainRGBA(
this.terrainSource(),
this.mapW,
this.mapH,
terrainColors,
),
);
}
@@ -94,10 +104,8 @@ export class TerrainPass {
* Update a subset of terrain tiles in-place (e.g. land→water from a water
* nuke). `bytes[i]` is the new terrain byte for `refs[i]` (parallel arrays).
* One 1×1 texSubImage2D per ref — fine for the small bursts a single nuke
* produces.
*
* Also writes back into `terrainBytes` so a later full re-upload (e.g.
* setTerrainColor) reflects these conversions instead of reverting them.
* produces. A later full re-upload (setTerrainColors) regenerates from
* terrainSource, whose backing game map already reflects these conversions.
*/
applyTerrainDelta(refs: readonly number[], bytes: Uint8Array): void {
if (refs.length === 0) return;
@@ -108,7 +116,6 @@ export class TerrainPass {
const ref = refs[i];
const x = ref % this.mapW;
const y = (ref - x) / this.mapW;
this.terrainBytes[ref] = bytes[i];
encodeTerrainTile(bytes[i], this.pixelScratch, 0, this.terrainColors);
gl.texSubImage2D(
gl.TEXTURE_2D,
+11 -7
View File
@@ -1,9 +1,10 @@
/**
* TrailPass — boat + nuke trail lines.
*
* Owns the CPU-side trail state (R16UI: 0=none, bits 0-11=ownerID, bit 12=nuke
* trail), the dirty-row bookkeeping for partial GPU uploads, and the trail
* Owns the dirty-row bookkeeping for partial GPU uploads and the trail
* fragment shader that draws the colored breadcrumb behind moving units.
* Trail state itself (R16UI: 0=none, bits 0-11=ownerID, bit 12=nuke trail)
* is referenced from the caller's array, not copied.
*/
import type { RenderSettings } from "../RenderSettings";
@@ -37,11 +38,14 @@ export class TrailPass {
// so the value stays small and sin()/fract() don't quantize over long sessions.
private readonly startTime = performance.now();
/** CPU-side trail state (R16UI: 0=none, owner in bits 0-11, nuke bit 12). */
private cpuTrailState: Uint16Array;
private trailsDirty = false;
/** Live-game reference — bypasses memcpy. Null for replay path. */
/**
* Reference to the caller-owned trail state (R16UI: 0=none, owner in bits
* 0-11, nuke bit 12). Every upload entry point provides it, so the pass
* keeps no copy of its own; the caller's array must stay current until the
* flush. Null until the first upload.
*/
private liveTrailRef: Uint16Array | null = null;
/** Dirty row range for partial trail upload. Infinity/-1 = full upload. */
@@ -64,7 +68,6 @@ export class TrailPass {
this.trailTex = trailTex;
this.paletteTex = paletteTex;
this.effectTex = effectTex;
this.cpuTrailState = new Uint16Array(mapW * mapH);
this.program = createProgram(
gl,
@@ -128,8 +131,9 @@ export class TrailPass {
/** Flush trail texture to GPU. Called once per render frame in uploadTextures. */
flushTexture(): void {
if (!this.trailsDirty) return;
const src = this.liveTrailRef;
if (src === null) return; // dirty is only ever set alongside the ref
const gl = this.gl;
const src = this.liveTrailRef ?? this.cpuTrailState;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.trailTex);
+405
View File
@@ -0,0 +1,405 @@
/**
* Main-thread memory harness: drives a real singleplayer game in headless
* Chromium and measures the page's JS heap with forced-GC checkpoints and
* full V8 heap snapshots taken over the Chrome DevTools Protocol.
*
* The core simulation runs in a Web Worker, so a page-session snapshot
* isolates the MAIN thread: GameView state, rendering layers, UI components.
* Snapshots are the standard V8 format — analyze them with
* npx tsx tests/perf/fullgame/HeapSnapshotRetainers.ts <file> [top]
* (or HeapSnapshotSummary.ts for multi-GB files).
*
* The harness starts its own vite dev server on a private port (default
* 9017) so results always come from THIS checkout, even when another
* working copy is serving port 9000.
*
* One-time browser setup (installs playwright + chromium libs, no sudo):
* bash .claude/skills/run-openfront/setup.sh
*
* Usage:
* npm run perf:client-mem -- --map "Giant World Map" --ticks 3000 \
* --window 500 --snapshot-at 0,3000
*
* Flags:
* --map <name> GameMapType value (default "Giant World Map")
* --bots <n> bot count (default 400, the solo-modal default)
* --difficulty <d> Easy|Medium|Hard|Impossible (default modal default)
* --ticks <n> run until this game tick (default 3000)
* --window <n> checkpoint every n ticks (default 500)
* --snapshot-at <list> comma-separated ticks to snapshot; 0 = post-spawn
* --spawn <x,y> fixed human spawn tile (default: auto-pick),
* for run-to-run repeatability on a given map
* --port <n> vite dev-server port (default 9017)
* --raf-interval <ms> rAF throttle; SwiftShader frames cost seconds of
* CPU, so an unthrottled frame loop starves the sim
* (default 3000)
* --out-dir <dir> output dir (default tests/perf/output)
*
* Headless caveats: rendering uses SwiftShader, so GPU texture memory lives
* in the GPU process and is NOT in these numbers — this measures main-thread
* JS heap + ArrayBuffers (which is where GameView, layers, and pixel staging
* buffers live). Solo games are RNG-driven (bot spawns), so numbers vary a
* few percent run-to-run; compare trends, not bytes.
*/
import { ChildProcess, spawn as spawnProcess } from "child_process";
import fs from "fs";
import path from "path";
interface Options {
map: string;
bots: number;
difficulty: string | undefined;
ticks: number;
window: number;
snapshotAt: number[];
spawn: { x: number; y: number } | null;
port: number;
rafIntervalMs: number;
outDir: string;
}
interface Checkpoint {
label: string;
ticks: number;
wallMs: number;
jsHeapUsedBytes: number;
jsHeapTotalBytes: number;
backingStoreBytes: number;
embedderHeapBytes: number;
domNodes: number;
jsEventListeners: number;
documents: number;
}
function parseArgs(): Options {
const opts: Options = {
map: "Giant World Map",
bots: 400,
difficulty: undefined,
ticks: 3000,
window: 500,
snapshotAt: [],
spawn: null,
port: 9017,
rafIntervalMs: 3000,
outDir: path.join("tests", "perf", "output"),
};
const argv = process.argv.slice(2);
for (let i = 0; i < argv.length; i++) {
const next = () => argv[++i];
switch (argv[i]) {
case "--map":
opts.map = next();
break;
case "--bots":
opts.bots = parseInt(next(), 10);
break;
case "--difficulty":
opts.difficulty = next();
break;
case "--ticks":
opts.ticks = parseInt(next(), 10);
break;
case "--window":
opts.window = parseInt(next(), 10);
break;
case "--snapshot-at":
opts.snapshotAt = next()
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((n) => Number.isFinite(n));
break;
case "--spawn": {
const [x, y] = next().split(",").map(Number);
opts.spawn = { x, y };
break;
}
case "--port":
opts.port = parseInt(next(), 10);
break;
case "--raf-interval":
opts.rafIntervalMs = parseInt(next(), 10);
break;
case "--out-dir":
opts.outDir = next();
break;
default:
throw new Error(`unknown flag: ${argv[i]}`);
}
}
return opts;
}
// ---------- dev server ----------
async function startViteServer(port: number): Promise<ChildProcess> {
// --strictPort makes vite exit instead of silently picking another port —
// that also guards against measuring a different checkout's server.
const child = spawnProcess(
"npx",
["vite", "--port", String(port), "--strictPort"],
{
env: { ...process.env, SKIP_BROWSER_OPEN: "true" },
stdio: ["ignore", "pipe", "pipe"],
detached: true, // own process group, so cleanup kills vite's children
},
);
let output = "";
child.stdout?.on("data", (d: Buffer) => (output += d.toString()));
child.stderr?.on("data", (d: Buffer) => (output += d.toString()));
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(
`vite exited with code ${child.exitCode} (port ${port} busy?)\n${output}`,
);
}
try {
const res = await fetch(`http://localhost:${port}/`);
if (res.ok) return child;
} catch {
// not up yet
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`vite did not become ready on port ${port}\n${output}`);
}
function stopViteServer(child: ChildProcess): void {
if (child.pid !== undefined && child.exitCode === null) {
try {
process.kill(-child.pid, "SIGTERM"); // whole process group
} catch {
// already gone
}
}
}
// ---------- report ----------
const fmtMB = (bytes: number): string => (bytes / 1024 / 1024).toFixed(1);
function printReport(checkpoints: Checkpoint[], opts: Options): void {
console.log(
`\n=== Main-thread memory (map=${opts.map}, bots=${opts.bots}) ===`,
);
console.log(
`${"label".padEnd(12)} ${"ticks".padStart(6)} ${"heapUsed".padStart(9)} ${"heapTotal".padStart(10)} ${"buffers".padStart(9)} ${"domNodes".padStart(9)} ${"listeners".padStart(9)} ${"ticks/s".padStart(8)}`,
);
let prev: Checkpoint | null = null;
for (const c of checkpoints) {
const rate =
prev !== null && c.wallMs > prev.wallMs
? ((c.ticks - prev.ticks) / ((c.wallMs - prev.wallMs) / 1000)).toFixed(
1,
)
: "-";
console.log(
`${c.label.padEnd(12)} ${String(c.ticks).padStart(6)} ${fmtMB(c.jsHeapUsedBytes).padStart(6)} MB ${fmtMB(c.jsHeapTotalBytes).padStart(7)} MB ${fmtMB(c.backingStoreBytes).padStart(6)} MB ${String(c.domNodes).padStart(9)} ${String(c.jsEventListeners).padStart(9)} ${rate.padStart(8)}`,
);
prev = c;
}
}
// ---------- main ----------
async function main(): Promise<void> {
const opts = parseArgs();
fs.mkdirSync(opts.outDir, { recursive: true });
console.log(`starting vite on port ${opts.port}`);
const vite = await startViteServer(opts.port);
// The skill driver reads OPENFRONT_URL at import time, so set it before
// the dynamic imports below.
process.env.OPENFRONT_URL = `http://localhost:${opts.port}`;
const { launch, gotoHome, openSoloModal } =
// @ts-expect-error untyped .mjs skill module
await import("../../../.claude/skills/run-openfront/driver.mjs");
const { startSoloGame, spawn, waitForSpawnPhaseEnd, gameState } =
// @ts-expect-error untyped .mjs skill module
await import("../../../.claude/skills/run-openfront/game.mjs");
let browser: { close(): Promise<void> } | null = null;
try {
console.log("launching headless chromium…");
const launched = await launch({
rafIntervalMs: opts.rafIntervalMs,
// Headless Chromium throttles backgrounded/occluded pages, which
// starves the singleplayer turn loop on top of SwiftShader's cost.
args: [
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
],
});
browser = launched.browser;
const page = launched.page;
// Headless Chromium only has SwiftShader, and the WebGL gate (#4324)
// refuses software renderers by matching the unmasked renderer string.
// Spoof the string so the gate passes; rendering still runs on
// SwiftShader (hence the rAF throttle).
await page.addInitScript(() => {
const orig = WebGL2RenderingContext.prototype.getParameter;
WebGL2RenderingContext.prototype.getParameter = function (p: number) {
const v = orig.call(this, p);
return typeof v === "string"
? v.replace(/swiftshader|llvmpipe|software/gi, "PerfHarnessGPU")
: v;
};
});
// Solo games are fully local: block external requests (ad scripts) and
// all websockets — vite's HMR socket times out under heavy throttling
// and force-reloads the page mid-game.
await page.route("**/*", (route: any) => {
const host = new URL(route.request().url()).hostname;
return host === "localhost" || host === "127.0.0.1"
? route.continue()
: route.abort();
});
await page.routeWebSocket("**", () => {});
// CDP session against the page = the main thread's isolate only (the
// core sim worker is a separate target and not included).
const cdp = await page.context().newCDPSession(page);
await cdp.send("HeapProfiler.enable");
await cdp.send("Performance.enable");
const startWall = Date.now();
const checkpoints: Checkpoint[] = [];
const checkpoint = async (label: string): Promise<Checkpoint> => {
// Two forced GCs: the first can leave finalizer garbage behind.
await cdp.send("HeapProfiler.collectGarbage");
await cdp.send("HeapProfiler.collectGarbage");
const { metrics } = await cdp.send("Performance.getMetrics");
const m = new Map<string, number>(
metrics.map((x: { name: string; value: number }) => [x.name, x.value]),
);
// JSHeapUsedSize excludes ArrayBuffer backing stores — where most
// render-layer memory lives. Newer CDP reports them here.
const usage = (await cdp.send("Runtime.getHeapUsage")) as {
usedSize: number;
totalSize: number;
backingStorageSize?: number;
embedderHeapUsedSize?: number;
};
const state = await gameState(page);
const c: Checkpoint = {
label,
ticks: state?.ticks ?? 0,
wallMs: Date.now() - startWall,
jsHeapUsedBytes: m.get("JSHeapUsedSize") ?? 0,
jsHeapTotalBytes: m.get("JSHeapTotalSize") ?? 0,
backingStoreBytes: usage.backingStorageSize ?? 0,
embedderHeapBytes: usage.embedderHeapUsedSize ?? 0,
domNodes: m.get("Nodes") ?? 0,
jsEventListeners: m.get("JSEventListeners") ?? 0,
documents: m.get("Documents") ?? 0,
};
checkpoints.push(c);
console.log(
`[checkpoint] ${label}: tick ${c.ticks}, heap ${fmtMB(c.jsHeapUsedBytes)} MB used / ${fmtMB(c.jsHeapTotalBytes)} MB total, buffers ${fmtMB(c.backingStoreBytes)} MB, ${c.domNodes} DOM nodes`,
);
return c;
};
const writeSnapshot = async (label: string): Promise<void> => {
const file = path.join(opts.outDir, `client-${label}.heapsnapshot`);
const ws = fs.createWriteStream(file);
const onChunk = (p: { chunk: string }) => ws.write(p.chunk);
cdp.on("HeapProfiler.addHeapSnapshotChunk", onChunk);
// All chunks are delivered before takeHeapSnapshot resolves.
await cdp.send("HeapProfiler.takeHeapSnapshot", {
reportProgress: false,
});
cdp.off("HeapProfiler.addHeapSnapshotChunk", onChunk);
await new Promise((r) => ws.end(r));
const mb = (fs.statSync(file).size / 1024 / 1024).toFixed(0);
console.log(`[snapshot] ${file} (${mb} MB)`);
};
console.log("loading home page + solo modal…");
await gotoHome(page);
await openSoloModal(page);
console.log(
`starting solo game: map=${opts.map}, bots=${opts.bots}` +
(opts.difficulty !== undefined
? `, difficulty=${opts.difficulty}`
: ""),
);
await startSoloGame(page, {
map: opts.map,
bots: opts.bots,
...(opts.difficulty !== undefined ? { difficulty: opts.difficulty } : {}),
});
await checkpoint("loaded");
console.log("spawning…");
const tile = await spawn(page, opts.spawn);
console.log(`spawned at (${tile.x},${tile.y})`);
await waitForSpawnPhaseEnd(page, 120_000);
await checkpoint("spawned");
const pendingSnapshots = [...new Set(opts.snapshotAt)].sort(
(a, b) => a - b,
);
const takeDueSnapshots = async (currentTick: number): Promise<void> => {
while (
pendingSnapshots.length > 0 &&
pendingSnapshots[0] <= currentTick
) {
await writeSnapshot(`tick${pendingSnapshots.shift()}`);
}
};
const spawnedTick = checkpoints[checkpoints.length - 1].ticks;
await takeDueSnapshots(spawnedTick);
const targets: number[] = [];
for (let t = spawnedTick + opts.window; t < opts.ticks; t += opts.window) {
targets.push(t);
}
if (opts.ticks > spawnedTick) targets.push(opts.ticks);
for (const target of targets) {
// Generous timeout: headless sim speed varies wildly with map size
// and bot count (0.510 ticks/s).
const timeout = opts.window * 2000 + 120_000;
await page.waitForFunction(
(t: number) => {
const g = (document.querySelector("build-menu") as any)?.game;
return g !== undefined && g.ticks() >= t;
},
target,
{ timeout, polling: 1000 },
);
const c = await checkpoint(`tick ${target}`);
await takeDueSnapshots(c.ticks);
}
// Anything requested beyond the reached tick range.
await takeDueSnapshots(Number.MAX_SAFE_INTEGER);
// End-of-run screenshot — a cheap rendering sanity check (a black map
// means the GL pipeline broke even if no pageerror surfaced).
const shotPath = path.join(opts.outDir, "client-final.png");
await page.screenshot({ path: shotPath });
console.log(`[screenshot] ${shotPath}`);
printReport(checkpoints, opts);
const finalState = await gameState(page);
console.log(
`\nfinal state: ${JSON.stringify(finalState)}\ntotal wall time ${((Date.now() - startWall) / 1000 / 60).toFixed(1)} min`,
);
} finally {
await browser?.close();
stopViteServer(vite);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+7 -1
View File
@@ -100,7 +100,13 @@ function main(): void {
}
}
const nodeName = (i: number): string => strings[nodes[i * NF + N_NAME]];
// Cap at one short line: string-type node names are the full string
// content (e.g. an entire script source for external strings).
const nodeName = (i: number): string => {
const raw = strings[nodes[i * NF + N_NAME]] ?? "";
const firstLine = raw.split("\n", 1)[0];
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}` : firstLine;
};
const nodeType = (i: number): string => nodeTypes[nodes[i * NF + N_TYPE]];
const edgeLabel = (i: number): string =>
retainerEdge[i] === -2 ? "[]" : (strings[retainerEdge[i]] ?? "?");