Profiler: real-GPU breakdown by zoom, look and scene, with a baseline (t88 phase 0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 18:31:43 -04:00
co-authored by Claude Opus 5.5
parent 0c0edf46ca
commit 9781aa3ee1
17 changed files with 38797 additions and 57 deletions
+14 -5
View File
@@ -20,7 +20,7 @@ src/ui/ the browser shell: input, HUD, build menu, notebook, setti
audio, the frame loop
test/ headless suites (t_*.js) and their harness; browser tests (play.py,
t_smooth.py, t_dev_console.py); run_all.py
tools/ serve.py (build, serve, rebuild on change), shot.py (screenshots),
tools/ serve.py (build, serve, rebuild on change), server.py (isolated HTTP), shot.py (screenshots),
where.py, map.py, perf.py, playthrough.py, use_venv.py (switches browser
scripts to .venv), scenes/ (screenshot setups), experiments/
(measurements and reports)
@@ -405,7 +405,7 @@ Screw casing geometry shared by both looks, ghosts and placement previews. smoot
- functions: diagonalScrewJoins, straightScrewJoins, diagonalScrewShape, diagonalScrewBlock
- variables: SCREW_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap
### src/render/world.js (404 lines)
### src/render/world.js (419 lines)
Scale colour without changing the smooth-look class.
- functions: fogColor, drawWorld
@@ -418,7 +418,7 @@ Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, t
- functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawHoveredVein, drawPingRecharge, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
- variables: PING_SPEED, PING_HOLD, PING_FADE, PING_BANDS, PING_GLINTS, GLINT_MS
### src/render/smooth.js (99 lines)
### src/render/smooth.js (103 lines)
Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution.
- functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace
- variables: SM, smBytes
@@ -553,7 +553,11 @@ Shared world colours, minimap and full map.
- functions: resetMap, mapArea, updateMap, drawMap, mapFit, drawFullMap, openMap, zoomMap, zoomMinimap
- variables: MAP_S, MAP
### src/ui/loop.js (140 lines)
### src/ui/perf.js (15 lines)
Browser-only clocks; simulation code stays usable without a browser or a clock.
- variables: PERF_MARKS, PERF_FRAME, perfMarks, perfTimes, perfLast
### src/ui/loop.js (148 lines)
The frame loop and boot.
- functions: scanVisible, frame, boot
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
@@ -583,6 +587,7 @@ The frame loop and boot.
- `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state.
- `test/t_molds.js`: Molds and furnaces stay usable: ingots drop below and keep their stubs, plugs over molds are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe.
- `test/t_nozzle.js`: Blowing aims within the nozzle's reach, with room for the brush and settling.
- `test/t_perf.js`: Profiling fixtures start in the same state after previous scenes and warm-up ticks.
- `test/t_physics.js`: Sand and other powders, molten metal and gases: heaps, sorting, sinking, setting, rising and fading. Water has its own suite (t_water.js). Tall worlds keep geothermal heat out of the way.
- `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore.
- `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation.
@@ -597,10 +602,14 @@ The frame loop and boot.
- `test/t_weather.js`: Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing.
- `test/t_world.js`: Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag refunds, drill reach, ballistic blowing, snowy mountains.
- `tools/map.py`: Regenerate the file and symbol index at the end of docs/PROJECT_MAP.md.
- `tools/perf.py`: Times the renderer and the simulation in a headless browser: light, drawWorld (with and without light and glow), the pixel upload and blit, and one simulation tick, in ms. Rebuild first.
- `tools/perf-measure.js`: Temporary wrappers live only in the profiling page, never in the simulation bundle.
- `tools/perf-scenes.js`: Seeded worlds, no storage round-trip (saves quantize heat and land flying grains).
- `tools/perf.py`: Profile a seeded world on the real GPU, with isolated HTTP serving. Times are milliseconds.
- `tools/playthrough.py`: Headless browser run: load the page, start a world, play a little, take screenshots.
- `tools/serve.py`: Build, serve and rebuild when sources change: python3 tools/serve.py [--port 8000].
- `tools/server.py`: Serve dist/ with isolation headers; isolated_server() owns a free-port background server.
- `tools/shot.py`: Screenshot the game in a headless browser, after setting up a scene with JavaScript.
- `tools/test_perf.py`: Profiler checks: headers, summaries/comparison, and unchanged pixels/simulation with clocks on.
- `tools/use_venv.py`: Let browser scripts run with plain python3 by switching to the repo's .venv.
- `tools/where.py`: Turn a line number in dist/index.html into the source file and line it came from.
- `tools/experiments/cast_sunk_mold.js`: Block-sized bit mold sunk into the ground with a stone furnace built over its funnel.
+61 -2
View File
@@ -106,7 +106,7 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
## Pitfalls
- **WebGL in headless Chromium.** There is no GPU, so the smooth look needs SwiftShader:
- **WebGL in headless Chromium.** The browser tests use SwiftShader for portable graphics checks:
launch with `--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader`. Also set
`window.__allowSoftwareGL = true` before the page loads (`add_init_script`); otherwise the game
sees software rendering and switches to the pixel look. `tools/shot.py` does both.
@@ -128,7 +128,66 @@ python3 tools/shot.py shots/silo.png --setup tools/scenes/clear-sky.js \
## Measuring
- `tools/perf.py`: renderer and tick times in the browser.
- `tools/perf.py`: the phase 0 CPU/frame profiler, served over HTTP with COOP/COEP isolation.
Rebuilds automatically. Run on an otherwise idle machine, without another browser test:
```sh
python3 tools/perf.py --json tools/perf-baseline.json
python3 tools/perf.py --json /tmp/perf-now.json --compare tools/perf-baseline.json
python3 tools/perf.py --viewport 1920x1080 --world 2048x1024 --zooms 12,6,4,3,2 \
--looks pixel,smooth --scenes settled,factory,heat --seconds 10 --json /tmp/perf.json
python3 tools/test_perf.py
```
The default matrix is three scenes × two looks × five zooms × lighting full/off. The world
seed is 424242; fog is off so all visible grains are shaded. Settings otherwise use game
defaults (including glow and the minimap), with autosave, camera lead and shake disabled.
`settled` runs 600 ticks; `factory` reuses the furnace/transport layout from
`experiments/factory_cost.js`, adding screws, a sand filter and full silos for sustained sand
and ore flow; `heat` places water over magma beside a rock floor. The latter two settle for
another 60 ticks. Each rebuild resets the power-rebuild scheduler, weather random stream and all statistics to
fresh-page values; fixture hashes are checked by `test/t_perf.js`. No save/load snapshot is used: it would quantize heat and land flyers.
**World** is the whole `drawWorld` CPU call. **Light** is the lighting work inside that call;
**Prep** sets up the view/masks/joins; **Colour** fills CPU grain buffers (including ghosts and
flyers); **Upload** submits the pixel buffer or smooth textures; **Blit** submits drawing,
including pixel light/glow composition or the smooth shader and Canvas2D copy. These are CPU
wall times, including driver stalls; they are not GPU timestamp measurements. In pixel look,
the glow upload is part of Blit. The separate `computeLight_ms` times an unconditional light
call even when the row has lighting off. World includes Light: do not add them together.
Overlay totals and each individual draw call are in JSON, with ms/frame and invocation counts; conditional
overlays absent from the fixture have zero calls, not a measured zero execution cost.
Five calls warm each render configuration before 30 measured calls. Simulation warms five
full 120-tick periods on a disposable scene, then rebuilds the same seeded fixture and
measures 240 consecutive ticks. Every direct `simTick` callee is discovered from its source;
JSON records per-call and amortized per-tick costs and invocation counts, including periodic
functions. Inactive paths (e.g. load wake, saplings, flow tracking) have zero calls and null
per-call cost. Unattributed time includes tick bookkeeping and clock/wrapper overhead.
Awake/hot chunk counts are recorded at the start/end of simulation and real runs.
Real-loop tests independently rebuild each scene for zooms 2 and 6 in both requested looks,
regardless of `--zooms`. `--seconds` applies to each of these twelve default runs. Raw samples
retain frame interval (rAF timestamps, including waiting/scheduling), CPU frame work, total
simulation work per frame and number of ticks. p50/p95/p99 use interpolated sorted samples;
five warm-up frames are excluded. Zero-tick frames correctly have zero simulation time.
JSON also records the smooth renderer's adaptive resolution scale: compare it as well as time.
Comparisons require identical options, print all timing changes, and mark >10% regressions;
tiny timings and tails can be noisy, and a zero baseline has no meaningful percent change.
Performance runs launch Chromium with
`--use-angle=vulkan --enable-features=Vulkan --enable-gpu --ignore-gpu-blocklist --enable-gpu-rasterization`.
They record the unmasked GPU/renderer, cores, browser version, viewport/world size,
`crossOriginIsolated`, `navigator.gpu`, source hash and elapsed time. They refuse software
renderers or missing isolation. Chromium needs access to the host GPU devices; a filesystem
sandbox that hides `/dev/dri`/NVIDIA devices must be lifted for this measurement. The browser
tests' SwiftShader numbers are not a performance baseline.
The existing in-game Performance stats toggle shows smoothed CPU world/light/overlay times
and movement/heat/rest per simulation tick. Detailed renderer clocks are off by default
(`PERF_MARKS = false`); the raw frame hook (`PERF_FRAME`) is also off outside the profiler.
`tools/server.py` shares the isolation/no-cache headers with `tools/serve.py`, and supplies a
free-port background server with cleanup for measurement tools.
- `tools/experiments/factory_cost.js`: what running factories cost the simulation.
- `tools/experiments/flow.js`: belt and screw conveyor throughput in grains a second.
- `tools/experiments/worldgen.js`: material counts and timings of world generation.
+17
View File
@@ -4,6 +4,23 @@ What agents have done, newest first. Add an entry for every piece of work: the d
note or request it was for, what changed, the files touched, the tests run, and anything left
open or not verified. Keep each entry short; the git history has the details.
## 2026-10-27: t88 phase 0, profiler and baseline (Claude Code + Codex)
- `tools/perf.py` rewritten: real GPU (ANGLE Vulkan flags), served by the new `tools/server.py`
with COOP/COEP (`tools/serve.py` sends them too), zooms 12/6/4/3/2 x pixel/smooth x scenes
settled/factory/heat (`tools/perf-scenes.js`), drawWorld split by `PERF_MARKS`, per-step sim
timings, 10 s real runs with p50/p95/p99, `--json`/`--compare`. Baseline `tools/perf-baseline.json`.
In-game perf readout shows world, light, overlays, move, heat, rest (`src/ui/perf.js`).
- Baseline (1920x1080, RTX 5070): zoom 2 smooth heat scene drawWorld 27.4 ms (colour loop 21.1,
light 5.5, upload 0.6); sim 3.7-4.9 ms/tick (simMove 2.0-2.3, heatPass 1.5-2.6); real loop at
zoom 2 runs at 30 fps (CPU work p50 30-37 ms), at zoom 6 at 60 fps (7-9 ms).
- Files: `tools/perf.py`, `tools/server.py`, `tools/serve.py`, `tools/perf-measure.js`,
`tools/perf-scenes.js`, `tools/test_perf.py`, `tools/perf-baseline.json`, `src/ui/perf.js`,
`src/render/world.js`, `src/render/smooth.js`, `src/ui/loop.js`, `src/styles/style.css`,
`src/manifest.json`, `test/t_perf.js`, `docs/TESTING.md`, `docs/PROJECT_MAP.md`.
- Tests: `python3 test/run_all.py --browser` all passed. Not measured yet: GPU time (timestamp
queries come with WebGPU), Firefox.
## 2026-10-27: OS pointer hidden over the game (Claude Code)
- The canvas used `cursor: crosshair`, so the system pointer showed on top of the game's own aim;
+1
View File
@@ -80,6 +80,7 @@
"ui/preview-selection.js",
"ui/events.js",
"ui/map.js",
"ui/perf.js",
"ui/loop.js"
]
}
+4
View File
@@ -64,6 +64,7 @@ function smoothDraw(lighting, glowOn) {
gl.viewport(0, 0, cw, ch);
gl.useProgram(SM.prog);
const bw = R.bw, bh = R.bh;
const uploadStart = PERF_MARKS ? performance.now() : 0;
smUpload('tCol', bw, bh, smBytes(R.px));
smUpload('tColL', bw, bh, smBytes(R.px));
smUpload('tBack', bw, bh, smBytes(R.backPx));
@@ -71,6 +72,8 @@ function smoothDraw(lighting, glowOn) {
smUpload('tField', bw, bh, smBytes(R.fieldPx));
if (lighting && R.lightImg) smUpload('tLight', R.lw, R.lh, smBytes(R.lightImg.data)); else smUpload('tLight', 1, 1, SM.white);
if (glowOn) smUpload('tGlow', R.glowC.width, R.glowC.height, smBytes(R.glowImg.data)); else smUpload('tGlow', 1, 1, SM.white);
const blitStart = PERF_MARKS ? performance.now() : 0;
if (PERF_MARKS) { perfMarks.upload = blitStart - uploadStart; perfMarks.prepare += uploadStart - t0; }
gl.uniform2f(u.uSize, bw, bh);
gl.uniform2f(u.uOrigin, R.vx0, R.vy0);
gl.uniform4f(u.uView, R.camX - R.vx0, R.camY - R.vy0, R.zp * (ch / R.canvas.height), ch);
@@ -85,6 +88,7 @@ function smoothDraw(lighting, glowOn) {
const ctx = R.ctx;
ctx.imageSmoothingEnabled = true;
ctx.drawImage(cv, 0, 0, cw, ch, 0, 0, R.canvas.width, R.canvas.height);
if (PERF_MARKS) perfMarks.blit = performance.now() - blitStart;
smoothPace(performance.now() - t0);
}
// Keep the frame rate up: drop the GPU pass's resolution when it is slow, raise it again when there
+16 -1
View File
@@ -3,6 +3,8 @@ function fogColor(c, k) { return (c & 0xff000000) | (((c >>> 16 & 255) * k) << 1
// drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth look.
// ---------- World pixels ----------
function drawWorld() {
const perfStart = PERF_MARKS ? performance.now() : 0;
if (PERF_MARKS) perfMarks.light = 0;
const bw = R.bw, bh = R.bh, px = R.px;
R.vx0 = Math.floor(R.camX); R.vy0 = Math.floor(R.camY);
const vx0 = R.vx0, vy0 = R.vy0;
@@ -31,7 +33,12 @@ function drawWorld() {
const useFog = SET.fog;
const OUT = sm ? 0x04000000 : 0xff000000; // unexplored and off the map: black
const lighting = SET.lighting !== 'off' && !R.lens;
if (lighting && (R.frame % SET.lightEvery === 0 || !R.lw)) computeLight();
if (lighting && (R.frame % SET.lightEvery === 0 || !R.lw)) {
const lightStart = PERF_MARKS ? performance.now() : 0;
computeLight();
if (PERF_MARKS) perfMarks.light = performance.now() - lightStart;
}
const colorStart = PERF_MARKS ? performance.now() : 0;
const grain = SET.grain, incand = SET.incandescence;
const anim = R.frame;
const lens = R.lens, tl = THERM[SET.thermalPalette] || THERM.inferno;
@@ -374,8 +381,15 @@ function drawWorld() {
}
}
}
if (PERF_MARKS) {
perfMarks.color = performance.now() - colorStart;
perfMarks.prepare = colorStart - perfStart - perfMarks.light;
}
if (sm) { smoothDraw(lighting, glowOn); return; }
const uploadStart = PERF_MARKS ? performance.now() : 0;
R.bctx.putImageData(R.img, 0, 0);
const blitStart = PERF_MARKS ? performance.now() : 0;
if (PERF_MARKS) perfMarks.upload = blitStart - uploadStart;
const ctx = R.ctx, zp = R.zp;
ctx.imageSmoothingEnabled = false;
const ox = -(R.camX - vx0) * zp, oy = -(R.camY - vy0) * zp;
@@ -401,4 +415,5 @@ function drawWorld() {
ctx.filter = 'none';
ctx.restore();
}
if (PERF_MARKS) perfMarks.blit = performance.now() - blitStart;
}
+1
View File
@@ -218,6 +218,7 @@ table.mt td.dip { color: var(--melt); }
#perf { position: fixed; right: max(16px, env(safe-area-inset-right, 0px)); bottom: calc(max(12px, env(safe-area-inset-bottom, 0px)) + 4px); font-family: var(--font-mono); font-size: 11px; color: var(--ash); pointer-events: none; text-align: right; white-space: pre; }
#hud .br { bottom: max(12px, env(safe-area-inset-bottom, 0px)); }
#perf:not(:empty) { background: var(--plate); color: var(--bone); padding: 6px 8px; border-radius: 4px; }
#perf:not(:empty) ~ .br { }
/* ---------- modals ---------- */
+11 -3
View File
@@ -19,6 +19,7 @@ function frame(now) {
if (GAME.loading) { acc = 0; return; }
if (!GAME.running) { pollPad(); return; }
const t0 = performance.now();
let frameSim = 0, frameSteps = 0;
const pad = pollPad();
if (GAME.usingPad) { GAME.aim.x = bot.x + bot.w / 2 + GAME.padAim.x; GAME.aim.y = bot.y + bot.h / 2 + GAME.padAim.y; }
else { const w = screenToWorld(GAME.mouse.x, GAME.mouse.y); GAME.aim.x = w.x; GAME.aim.y = w.y; }
@@ -37,7 +38,8 @@ function frame(now) {
GAME.padRun = pm > 0.92 ? GAME.padRun + 1 : 0;
botStep(ix, iy, keys.has('Shift') || GAME.padRun > 24);
toolTick();
const s0 = performance.now(); simTick(); simMs = simMs * 0.9 + (performance.now() - s0) * 0.1;
const s0 = performance.now(); simTick(); const elapsed = performance.now() - s0;
simMs = simMs * 0.9 + elapsed * 0.1; frameSim += elapsed; frameSteps++;
if (devPaused) devSteps--; else acc -= step;
steps++;
GAME.playTime += step;
@@ -56,7 +58,10 @@ function frame(now) {
if (!GAME.drag || Math.hypot(bot.vx, bot.vy) > 0.08) updateCamera(dt, lookX, lookY);
{ const v = viewCells(); setBuildRange(R.camX + v.w / 2, R.camY + v.h / 2, v.w, v.h); }
R.frame++;
if (SET.showPerf) perfLast.computeLight = 0;
drawWorld();
if (SET.showPerf) perfTimes.computeLight = (perfTimes.computeLight || 0) * 0.9 + perfLast.computeLight * 0.1;
const overlayStart = SET.showPerf ? performance.now() : 0;
drawCracks();
if (!weather.seen && (R.rain || 0) > 0.12) {
weather.seen = true;
@@ -81,6 +86,7 @@ function frame(now) {
drawRain();
drawBot();
if (!GAME.hidePreviews) drawPreviews(); // tools/shot.py hides them for clean screenshots
if (SET.showPerf) perfTimes.overlays = (perfTimes.overlays || 0) * 0.9 + (performance.now() - overlayStart) * 0.1;
if (GAME.replace !== GAME.replaceShown) { GAME.replaceShown = GAME.replace; hudDirty = true; }
renderBuildBar();
if (hudDirty || R.frame % 6 === 0) { renderSelCard(); renderBag(); renderWallet(); hudDirty = false; }
@@ -89,11 +95,13 @@ function frame(now) {
updateOverlayUI();
if (now > ambientT) { ambientT = now + 200; auAmbient(scanVisible()); }
updateMap(now);
frameMs = frameMs * 0.9 + (performance.now() - t0) * 0.1;
const frameWork = performance.now() - t0;
frameMs = frameMs * 0.9 + frameWork * 0.1;
if (PERF_FRAME) PERF_FRAME({ at: now, work: frameWork, sim: frameSim, steps: frameSteps });
fpsSm = fpsSm * 0.95 + (1 / Math.max(0.001, dt)) * 0.05;
if (SET.showPerf && R.frame % 15 === 0) {
let aw = 0, ht = 0; for (let c = 0; c < NCH; c++) { if (awake[c]) aw++; if (hot[c]) ht++; }
$('perf').textContent = `${fpsSm.toFixed(0)} fps · frame ${frameMs.toFixed(1)} ms · sim ${simMs.toFixed(1)} ms/tick\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`;
$('perf').textContent = `${fpsSm.toFixed(0)} fps · frame ${frameMs.toFixed(1)} ms · sim ${simMs.toFixed(1)} ms/tick (move ${(perfTimes.simMove || 0).toFixed(1)}, heat ${(perfTimes.heatPass || 0).toFixed(1)}, rest ${Math.max(0, simMs - (perfTimes.simMove || 0) - (perfTimes.heatPass || 0)).toFixed(1)})\nworld ${(perfTimes.drawWorld || 0).toFixed(1)} ms · light ${(perfTimes.computeLight || 0).toFixed(1)} ms · overlays ${(perfTimes.overlays || 0).toFixed(1)} ms\nmoving areas ${aw} · hot areas ${ht} / ${NCH} · bars ${ents.length} · view ${R.bw}×${R.bh}`;
}
}
+15
View File
@@ -0,0 +1,15 @@
// Browser-only clocks; simulation code stays usable without a browser or a clock.
let PERF_MARKS = false, PERF_FRAME = null;
const perfMarks = {}, perfTimes = {}, perfLast = {};
for (const name of ['drawWorld', 'computeLight', 'simMove', 'heatPass']) {
const original = window[name];
window[name] = function (...args) {
if (!SET.showPerf && !PERF_MARKS) return original.apply(this, args);
const start = performance.now();
const result = original.apply(this, args);
const ms = performance.now() - start;
perfLast[name] = ms;
if (name !== 'computeLight') perfTimes[name] = (perfTimes[name] || 0) * 0.9 + ms * 0.1;
return result;
};
}
+31
View File
@@ -0,0 +1,31 @@
// Profiling fixtures start in the same state after previous scenes and warm-up ticks.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const { load, suite } = require('./harness');
const { run } = load();
const { check, done } = suite();
// Only the fixture's browser shell is stubbed; all simulation functions are real.
run(`const GAME = {}, R = {particles: [], camX: 0, camY: 0}, SET = {};
const document = {querySelectorAll: () => []}; let acc;
function enterGame() {} function snapCamera() {}`);
run(fs.readFileSync(path.join(__dirname, '../tools/perf-scenes.js'), 'utf8'));
function fixture(name) {
run(`perfScene('${name}', 1024, 640);`);
const hash = crypto.createHash('sha256');
for (const name of ['mat', 'temp', 'flags', 'part', 'awake', 'hot'])
hash.update(run(`Buffer.from(${name}.buffer)`));
hash.update(run('JSON.stringify([RS, WS, stats])'));
return hash.digest('hex');
}
const first = fixture('factory');
check('factory fixture repeats exactly', fixture('factory') === first);
run('for (let k = 0; k < 600; k++) simTick();');
check('factory fixture repeats after a warmed simulation', fixture('factory') === first);
const moved = run('stats.moved');
run('for (let k = 0; k < 240; k++) simTick();');
check('factory still transports grains after four seconds', run('stats.moved') > moved + 1000);
check('both factory feeds remain active', run('machines.filter(m => m && m.type === "silo" && tick - m.pourT < 10).length') === 2);
check('factory has fuel remaining in both silos', run('machines.filter(m => m && m.type === "silo" && m.sn > 0).length') === 2);
const heat = fixture('heat');
fixture('settled');
check('heat fixture repeats after another scene', fixture('heat') === heat);
done();
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
// Temporary wrappers live only in the profiling page, never in the simulation bundle.
function perfWrap(names, data) {
const originals = {};
for (const name of names) {
if (typeof window[name] !== 'function') throw Error('Missing profiler target: ' + name);
const f = originals[name] = window[name];
data[name] = { ms: 0, calls: 0 };
window[name] = function (...args) {
const t = performance.now();
try { return f.apply(this, args); }
finally { data[name].ms += performance.now() - t; data[name].calls++; }
};
}
return () => { for (const name of names) window[name] = originals[name]; };
}
function perfSim(names, scene, w, h) {
// Five full periods warm even the 120-tick cold pass without forcing conditional calls.
for (let k = 0; k < 600; k++) simTick();
perfScene(scene, w, h);
const start = perfState(), data = {}, restore = perfWrap(names, data), n = 240;
const t = performance.now();
try { for (let k = 0; k < n; k++) simTick(); }
finally { restore(); }
const total = (performance.now() - t) / n;
const steps = {};
for (const [name, d] of Object.entries(data)) steps[name] = {
ms_per_tick: d.ms / n, ms_per_call: d.calls ? d.ms / d.calls : null, calls: d.calls
};
return { total_ms: total, ticks: n, start, end: perfState(), steps,
rest_ms: total - Object.values(data).reduce((s, d) => s + d.ms / n, 0) };
}
function perfRender(lighting, overlays) {
SET.lighting = lighting;
PERF_MARKS = true;
try {
for (let k = 0; k < 5; k++) { R.frame++; drawWorld(); }
const marks = {}, n = 30;
let draw = 0;
for (let k = 0; k < n; k++) {
R.frame++; drawWorld();
draw += perfLast.drawWorld / n;
for (const [key, value] of Object.entries(perfMarks)) marks[key] = (marks[key] || 0) + value / n;
}
for (let k = 0; k < 5; k++) computeLight();
const l0 = performance.now();
for (let k = 0; k < n; k++) computeLight();
const light = (performance.now() - l0) / n;
// Exercise the actual overlay calls and their arguments/conditions in frame().
PERF_MARKS = false;
for (let k = 0; k < 5; k++) frame(lastT + 1000 / 60);
const data = {}, restore = perfWrap(overlays, data);
try { for (let k = 0; k < n; k++) frame(lastT + 1000 / 60); }
finally { restore(); }
return { drawWorld_ms: draw, computeLight_ms: light, marks_ms: marks,
overlays: Object.fromEntries(Object.entries(data).map(([key, d]) => [key, { ms_per_frame: d.ms / n, calls: d.calls }])),
overlay_ms: Object.values(data).reduce((sum, d) => sum + d.ms / n, 0),
scale: SM.scale, actual_look: SET.look };
} finally { PERF_MARKS = false; }
}
function perfRun(seconds) {
return new Promise(resolve => {
SET.lighting = 'full';
let warm = 5, previous = null;
const samples = [];
GAME.paused = false; acc = 0; lastT = performance.now(); lastRender = lastT;
PERF_FRAME = s => {
if (warm-- > 0) { previous = s.at; return; }
samples.push({ interval: s.at - previous, work: s.work, sim: s.sim, steps: s.steps });
previous = s.at;
};
window.__perfStop = false;
requestAnimationFrame(frame);
setTimeout(() => {
window.__perfHalt(); PERF_FRAME = null; GAME.paused = true;
resolve({ samples, end: perfState(), scale: SM.scale, actual_look: SET.look });
}, seconds * 1000);
});
}
+66
View File
@@ -0,0 +1,66 @@
// Seeded worlds, no storage round-trip (saves quantize heat and land flying grains).
function perfScene(name, w, h) {
GAME.running = false;
// Match a fresh page: generation retains the power scheduler and weather random stream.
nextPowerBuild = 0; WS = 0x1b873593 | 0;
for (const key of Object.keys(stats)) stats[key] = 0;
generateWorld(424242, w, h, null);
for (let k = 0; k < 600; k++) simTick();
if (name !== 'settled') {
const x0 = Math.max(8, (W / 4 - 100) & ~3), y0 = Math.max(16, Math.min(H - 176, (surfY[Math.max(8, W / 4 | 0)] - 100) & ~3));
for (let y = y0; y < y0 + 160; y++) for (let x = x0; x < x0 + 200; x++) {
setCell(y * W + x, y >= y0 + 144 ? STONE : EMPTY, AMB[y], 0);
bgm[y * W + x] = 0;
}
bot.x = x0 + 98; bot.y = y0 + 50;
if (name === 'factory') {
// Furnace and hopper from experiments/factory_cost.js, with a screw/filter line.
const mx = x0 / 4 + 3, my = y0 / 4 + 16;
const place = (code, v, x, y, extra) => {
const error = placeBlock(code, v, x, y, true, extra);
if (error) throw Error(`factory ${x},${y}: ${error}`);
};
for (let by = my; by < my + 4; by++) for (let bx = mx; bx < mx + 4; bx++) {
if ((bx > mx && bx < mx + 3 && by > my && by < my + 3) || (by === my && bx === mx + 2)) continue;
place(BP_WALL, RSTONE, bx, by);
}
for (let y = (my + 1) * 4 + 3; y < (my + 3) * 4; y++) for (let x = (mx + 1) * 4; x < (mx + 3) * 4; x++)
setCell(y * W + x, (x + y) & 3 ? COAL : CUORE, 700, 0);
for (let x = mx + 8; x < mx + 25; x++) place(BP_BELT, 1, x, my + 5);
for (let x = mx + 25; x < mx + 30; x++) place(BP_SCREW, 1, x, my + 4);
for (let x = mx + 30; x < mx + 40; x++) place(BP_FILTER, 128, x, my + 5, { m: [SAND], inv: 0 });
// Two full silos keep both materials flowing for the entire real run.
for (const [dx, material] of [[9, SAND], [33, CUORE]]) {
const silo = addMachine('silo', mx + dx, my - 2, 0);
silo.sm = material; silo.sn = SILO_CAP; silo.open = true;
}
// Start with a loaded line, rather than timing its initial travel delay.
for (let x = (mx + 8) * 4; x < (mx + 40) * 4; x++)
setCell(((my + 5) * 4 - 1) * W + x, x & 3 ? SAND : CUORE, AMB[(my + 5) * 4 - 1], 0);
} else {
for (let y = y0 + 100; y < y0 + 144; y++) for (let x = x0 + 12; x < x0 + 188; x++)
setCell(y * W + x, y < y0 + 114 ? WATER : MAGMA, y < y0 + 114 ? 20 : 1450, 0);
}
for (let k = 0; k < 60; k++) simTick();
}
enterGame(); GAME.paused = true;
SET.fog = false; SET.autosave = 0; SET.cameraLead = false; SET.shake = false;
SET.fpsCap = 0; SET.showPerf = false;
document.querySelectorAll('.card, .toast').forEach(e => e.remove());
R.particles.length = 0;
snapCamera(); acc = 0;
return perfState();
}
function perfState() {
let a = 0, h = 0;
for (let c = 0; c < NCH; c++) { if (awake[c]) a++; if (hot[c]) h++; }
return { tick, awake: a, hot: h, chunks: NCH, moved: stats.moved,
flyers: flyers.length, silos: machines.filter(m => m && m.type === 'silo').map(m => ({ material: m.sm, grains: m.sn, pouring: tick - m.pourT < 10 })), camera: [R.camX, R.camY], bot: [bot.x, bot.y] };
}
function perfView(look, zoom) {
SET.look = look; SET.zoom = zoom; SET.lighting = 'full';
resizeRender(); snapCamera();
for (let k = 0; k < 5; k++) { R.frame++; drawWorld(); }
if (look === 'smooth' && (!SM.ok || !R.smoothOn)) throw Error('Smooth look unavailable: ' + SM.why);
return { grains: R.bw * R.bh, buffer: [R.bw, R.bh], scale: SM.scale };
}
+210 -32
View File
@@ -1,35 +1,213 @@
"""Times the renderer and the simulation in a headless browser: light, drawWorld (with and without
light and glow), the pixel upload and blit, and one simulation tick, in ms. Rebuild first.
#!/usr/bin/env python3
"""Profile a seeded world on the real GPU, with isolated HTTP serving. Times are milliseconds."""
import argparse
from datetime import datetime, timezone
import hashlib
import json
import math
from pathlib import Path
import re
import subprocess
import sys
import time
python3 tools/perf.py
"""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from use_venv import ensure_playwright; ensure_playwright()
from server import isolated_server
from use_venv import ensure_playwright
ROOT = Path(__file__).resolve().parent.parent
GPU_ARGS = ['--use-angle=vulkan', '--enable-features=Vulkan', '--enable-gpu',
'--ignore-gpu-blocklist', '--enable-gpu-rasterization']
def size(value):
try:
w, h = map(int, value.lower().split('x'))
if w < 320 or h < 240 or w > 8192 or h > 8192:
raise ValueError()
return w, h
except ValueError:
raise argparse.ArgumentTypeError('size must be WxH, between 320x240 and 8192x8192')
def quantiles(values):
if not values:
raise ValueError('No real-loop samples were collected')
ordered = sorted(values)
def percentile(p):
i = (len(ordered) - 1) * p
lo = math.floor(i)
return ordered[lo] + (ordered[math.ceil(i)] - ordered[lo]) * (i - lo)
return {name: percentile(p) for name, p in [('p50', .5), ('p95', .95), ('p99', .99)]}
def measurements(data, prefix=''):
"""Only timings, never counts, frame timestamps or hardware identifiers."""
for key, value in data.items():
path = f'{prefix}/{key}'
if isinstance(value, dict):
yield from measurements(value, path)
elif isinstance(value, (int, float)) and (
key.endswith('_ms') or key.startswith('ms_per_') or key in ('p50', 'p95', 'p99')
or prefix.endswith('/marks_ms')):
yield path, value
def report(result, baseline=None):
m = result['machine']
print(f"GPU: {m['renderer']}\nCores: {m['cores']} | isolated: {m['crossOriginIsolated']} | navigator.gpu: {m['webgpu']}")
print(f"Viewport {result['options']['viewport']} | world {result['options']['world']} | seed 424242 | ms (CPU wall time)")
print('\nScene Look Zoom Light Grains World Light Prep Colour Upload Blit Overlays Scale')
for scene, data in result['scenes'].items():
for key, view in data['views'].items():
look, zoom = key.split('/')
for light, r in view['lighting'].items():
p = r['marks_ms']
print(f"{scene:8} {look:6} {zoom:>4} {light:>5} {view['grains']:6d} "
f"{r['drawWorld_ms']:7.2f} {p['light']:6.2f} {p['prepare']:5.2f} {p['color']:6.2f} "
f"{p['upload']:6.2f} {p['blit']:6.2f} {r['overlay_ms']:8.3f} {r['scale']:5.2f}")
print('\nSimulation: average ms/tick (periodic work amortized over 240 ticks)')
names = list(next(iter(result['scenes'].values()))['sim']['steps'])
print(f"{'Step':24}" + ''.join(f'{s:>12}' for s in result['scenes']))
for name in ['TOTAL', *names, 'unattributed']:
values = []
for d in result['scenes'].values():
sim = d['sim']
values.append(sim['total_ms'] if name == 'TOTAL' else sim['rest_ms'] if name == 'unattributed' else sim['steps'][name]['ms_per_tick'])
print(f'{name:24}' + ''.join(f'{v:12.4f}' for v in values))
for scene, d in result['scenes'].items():
s = d['sim']
print(f" {scene}: awake {s['start']['awake']} -> {s['end']['awake']}, hot {s['start']['hot']} -> {s['end']['hot']} / {s['end']['chunks']}")
print('\nReal loop: frame interval / CPU frame work / sim per frame, p50/p95/p99 ms')
for scene, d in result['scenes'].items():
for key, r in d['runs'].items():
triples = ['/'.join(f'{v:.2f}' for v in r[k].values()) for k in ('interval_ms', 'work_ms', 'sim_ms')]
print(f"{scene:8} {key:9} n={len(r['samples']):4} " + ' | '.join(f'{v:>20}' for v in triples) + f" | scale {r['scale']:.2f}")
if baseline:
old = dict(measurements(baseline['scenes']))
print('\nComparison: change in every matching timing (>10% marked REGRESSION; zero baselines are n/a)')
for key, value in measurements(result['scenes']):
before = old.get(key)
if before is None:
print(f'{key}: new measurement')
elif before == 0:
print(f'{key}: n/a (baseline zero)')
else:
delta = 100 * (value / before - 1)
print(f"{key}: {delta:+.1f}%{' REGRESSION' if delta > 10 else ''}")
print(f"\nFull run: {result['elapsed_seconds']:.1f} seconds")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--viewport', type=size, default=(1920, 1080))
ap.add_argument('--world', type=size, default=(2048, 1024))
ap.add_argument('--zooms', default='12,6,4,3,2')
ap.add_argument('--looks', default='pixel,smooth')
ap.add_argument('--scenes', default='settled,factory,heat')
ap.add_argument('--seconds', type=float, default=10)
ap.add_argument('--json', type=Path)
ap.add_argument('--compare', type=Path)
args = ap.parse_args()
try:
zooms = list(dict.fromkeys(float(z) for z in args.zooms.split(',')))
except ValueError:
ap.error('--zooms must be comma-separated numbers')
looks, scenes = args.looks.split(','), args.scenes.split(',')
if not zooms or any(not math.isfinite(z) or z < 2 or z > 12 for z in zooms):
ap.error('zooms must be between 2 and 12')
if not looks or set(looks) - {'pixel', 'smooth'}:
ap.error('looks must be pixel,smooth')
if not scenes or set(scenes) - {'settled', 'factory', 'heat'}:
ap.error('scenes must be settled,factory,heat')
if not math.isfinite(args.seconds) or args.seconds <= 0:
ap.error('--seconds must be positive')
if args.world[0] % 32 or args.world[1] % 32:
ap.error('world dimensions must be multiples of 32')
options = dict(viewport=list(args.viewport), world=list(args.world), zooms=zooms,
looks=looks, scenes=scenes, seconds=args.seconds)
baseline = json.loads(args.compare.read_text()) if args.compare else None
if baseline and (baseline.get('schema') != 1 or baseline['options'] != options):
ap.error('baseline schema/options differ; use the same matrix, dimensions and seconds')
ensure_playwright()
from playwright.sync_api import sync_playwright
root = pathlib.Path(__file__).resolve().parent.parent
url = (root / 'dist' / 'index.html').as_uri()
with sync_playwright() as p:
b = p.chromium.launch(args=['--enable-gpu-rasterization','--ignore-gpu-blocklist'])
pg = b.new_page(viewport={'width': 1440, 'height': 860})
pg.goto(url); pg.evaluate("SET.fog = false"); pg.wait_for_timeout(300)
pg.evaluate("startNewWorld(424242, 1024, 640)")
pg.wait_for_function('GAME.running === true', timeout=20000)
pg.wait_for_timeout(500)
res = pg.evaluate('''() => {
const t = (f, n) => { const a = performance.now(); for (let k=0;k<n;k++) f(); return ((performance.now()-a)/n).toFixed(2); };
const out = {};
out.light = t(() => computeLight(), 20);
out.drawWorld = t(() => drawWorld(), 20);
SET.lighting = 'off'; out.drawWorldNoLight = t(() => drawWorld(), 20); SET.lighting='full';
SET.glow = 'off'; out.drawWorldNoGlow = t(() => drawWorld(), 20); SET.glow='low';
SET.fog = true; out.drawWorldFog = t(() => drawWorld(), 100);
SET.lighting = 'off'; out.drawWorldFogNoLight = t(() => drawWorld(), 100); SET.lighting = 'full'; SET.fog = false;
out.put = t(() => { R.bctx.putImageData(R.img,0,0); }, 20);
out.blit = t(() => { R.ctx.drawImage(R.buf, 0, 0, R.bw, R.bh, 0, 0, R.bw*R.zp, R.bh*R.zp); }, 20);
out.sim = t(() => simTick(), 60);
out.view = [R.bw, R.bh, R.zp, R.lw, R.lh];
return out;
started = time.monotonic()
subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True, stdout=sys.stderr)
# Derive coverage from the source so a newly added sim step cannot silently disappear.
tick_source = (ROOT / 'src/sim/tick.js').read_text()
steps = list(dict.fromkeys(re.findall(r'\b([A-Za-z_]\w*)\(', tick_source)))
steps = [n for n in steps if n not in ('simTick', 'if', 'for')]
overlays = list(dict.fromkeys(re.findall(r'\b(draw[A-Z]\w*)\(', (ROOT / 'src/ui/loop.js').read_text())))
overlays.remove('drawWorld')
# boot's title draw is outside frame().
overlays.remove('drawTitle')
result = dict(schema=1, created_utc=datetime.now(timezone.utc).isoformat(), options=options,
gpu_args=GPU_ARGS, scenes={}, sim_targets=steps, overlay_targets=overlays)
files = sorted((ROOT / 'src').rglob('*')) + [Path(__file__), ROOT / 'tools/perf-scenes.js', ROOT / 'tools/perf-measure.js']
result['source_sha256'] = hashlib.sha256(b''.join(p.read_bytes() for p in files if p.is_file())).hexdigest()
errors = []
with isolated_server() as url, sync_playwright() as p:
browser = p.chromium.launch(args=GPU_ARGS)
pg = browser.new_page(viewport=dict(width=args.viewport[0], height=args.viewport[1]))
pg.on('pageerror', lambda e: errors.append(str(e)))
pg.add_init_script('''window.__perfStop = true;
const raf = window.requestAnimationFrame.bind(window), pending = new Set();
window.__perfHalt = () => { window.__perfStop = true; for (const id of pending) cancelAnimationFrame(id); pending.clear(); };
window.requestAnimationFrame = f => {
if (window.__perfStop) return 0;
const id = raf(t => { pending.delete(id); f(t); }); pending.add(id); return id;
};''')
pg.goto(url)
pg.add_script_tag(content=(ROOT / 'tools/perf-scenes.js').read_text())
pg.add_script_tag(content=(ROOT / 'tools/perf-measure.js').read_text())
result['machine'] = pg.evaluate('''() => {
if (!smoothAvailable()) throw Error('Real GPU unavailable: ' + SM.why);
const gl = SM.gl, dbg = gl.getExtension('WEBGL_debug_renderer_info');
return { renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
vendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR),
cores: navigator.hardwareConcurrency, crossOriginIsolated, webgpu: !!navigator.gpu,
user_agent: navigator.userAgent, dpr: devicePixelRatio };
}''')
print(res)
b.close()
if not result['machine']['crossOriginIsolated']:
raise RuntimeError('Local server did not provide cross-origin isolation')
if re.search('swiftshader|llvmpipe|softpipe|software', result['machine']['renderer'], re.I):
raise RuntimeError('Refusing a software-rendered baseline')
result['machine']['browser_version'] = browser.version
def setup(scene):
return pg.evaluate('([s,w,h]) => perfScene(s,w,h)', [scene, *args.world])
for scene in scenes:
print(f'Measuring {scene}...', file=sys.stderr, flush=True)
setup(scene)
sim = pg.evaluate('([names,s,w,h]) => perfSim(names,s,w,h)', [steps, scene, *args.world])
initial = setup(scene)
data = result['scenes'][scene] = dict(initial=initial, sim=sim, views={}, runs={})
for look in looks:
for zoom in zooms:
view = pg.evaluate('([l,z]) => perfView(l,z)', [look, zoom])
view['lighting'] = {}
for light in ['full', 'off']:
view['lighting'][light] = pg.evaluate('([l,o]) => perfRender(l,o)', [light, overlays])
if view['lighting']['full']['actual_look'] != look or view['lighting']['off']['actual_look'] != look:
raise RuntimeError('Look fell back while measuring')
data['views'][f'{look}/{zoom:g}'] = view
for look in looks:
for zoom in [2, 6]:
setup(scene)
pg.evaluate('([l,z]) => perfView(l,z)', [look, zoom])
r = pg.evaluate('seconds => perfRun(seconds)', args.seconds)
if r['actual_look'] != look:
raise RuntimeError('Look fell back during real run')
for key in ['interval', 'work', 'sim']:
r[key + '_ms'] = quantiles([s[key] for s in r['samples']])
data['runs'][f'{look}/{zoom}'] = r
print(f' {look}/{zoom}: {len(r["samples"])} frames', file=sys.stderr, flush=True)
browser.close()
if errors:
raise RuntimeError('Page errors: ' + '; '.join(errors))
result['elapsed_seconds'] = time.monotonic() - started
if args.json:
args.json.write_text(json.dumps(result, indent=2) + '\n')
report(result, baseline)
if __name__ == '__main__':
main()
+1 -12
View File
@@ -1,23 +1,12 @@
#!/usr/bin/env python3
"""Build, serve and rebuild when sources change: python3 tools/serve.py [--port 8000]."""
import argparse, errno, http.server, pathlib, subprocess, sys, threading, time
from server import Handler
ROOT = pathlib.Path(__file__).resolve().parent.parent
DIST = ROOT / 'dist'
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(DIST), **kwargs)
def end_headers(self):
self.send_header('Cache-Control', 'no-store')
super().end_headers()
def log_message(self, format, *args):
pass
def build(first=False):
result = subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
+33
View File
@@ -0,0 +1,33 @@
"""Serve dist/ with isolation headers; isolated_server() owns a free-port background server."""
from contextlib import contextmanager
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from threading import Thread
DIST = Path(__file__).resolve().parent.parent / 'dist'
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(DIST), **kwargs)
def end_headers(self):
self.send_header('Cache-Control', 'no-store')
self.send_header('Cross-Origin-Opener-Policy', 'same-origin')
self.send_header('Cross-Origin-Embedder-Policy', 'require-corp')
super().end_headers()
def log_message(self, format, *args):
pass
@contextmanager
def isolated_server():
with ThreadingHTTPServer(('127.0.0.1', 0), Handler) as server:
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f'http://127.0.0.1:{server.server_port}'
finally:
server.shutdown()
thread.join()
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Profiler checks: headers, summaries/comparison, and unchanged pixels/simulation with clocks on."""
import contextlib
import io
import math
import copy
from urllib.request import urlopen
from perf import ROOT, GPU_ARGS, quantiles, measurements, report
from server import isolated_server
from use_venv import ensure_playwright
def main():
q = quantiles([4, 1, 3, 2])
assert all(math.isclose(q[k], v) for k, v in [('p50', 2.5), ('p95', 3.85), ('p99', 3.97)])
assert dict(measurements({'calls': 10, 'total_ms': 3, 'marks_ms': {'color': 2}})) == {
'/total_ms': 3, '/marks_ms/color': 2}
data = {'machine': {'renderer': 'test', 'cores': 1, 'crossOriginIsolated': True, 'webgpu': True},
'options': {'viewport': [1920, 1080], 'world': [2048, 1024]}, 'elapsed_seconds': 1,
'scenes': {'factory': {'views': {}, 'runs': {}, 'sim': {'total_ms': 1, 'rest_ms': 0,
'steps': {}, 'start': {'awake': 1, 'hot': 1}, 'end': {'awake': 1, 'hot': 1, 'chunks': 2}}}}}
newer = copy.deepcopy(data)
newer['scenes']['factory']['sim']['total_ms'] = 1.2
out = io.StringIO()
with contextlib.redirect_stdout(out):
report(newer, data)
assert '/factory/sim/total_ms: +20.0% REGRESSION' in out.getvalue()
assert 'n/a (baseline zero)' in out.getvalue()
ensure_playwright()
from playwright.sync_api import sync_playwright
with isolated_server() as url, sync_playwright() as p:
with urlopen(url) as response:
assert response.headers['Cross-Origin-Opener-Policy'] == 'same-origin'
assert response.headers['Cross-Origin-Embedder-Policy'] == 'require-corp'
response.read()
browser = p.chromium.launch(args=GPU_ARGS)
pg = browser.new_page(viewport={'width': 1280, 'height': 760})
errors = []
pg.on('pageerror', lambda e: errors.append(str(e)))
pg.add_init_script('window.requestAnimationFrame = () => 0;')
pg.goto(url)
pg.add_script_tag(content=(ROOT / 'tools/perf-scenes.js').read_text())
assert pg.evaluate('crossOriginIsolated')
# Compare to the unwrapped functions, and hash the byte representation of all grid state.
hashes = pg.evaluate('''() => {
const hash = () => {
let h = 2166136261;
for (const a of [mat, temp, flags, stamp, part, awake, hot])
for (const b of new Uint8Array(a.buffer, a.byteOffset, a.byteLength)) h = Math.imul(h ^ b, 16777619);
return h >>> 0;
};
const results = [];
for (const enabled of [false, true]) {
SET.showPerf = enabled;
generateWorld(424242, 640, 384, null);
for (let k = 0; k < 240; k++) simTick();
results.push(hash());
}
return results;
}''')
assert hashes[0] == hashes[1], hashes
pg.evaluate("perfScene('factory', 1024, 640)")
for look in ['pixel', 'smooth']:
identical = pg.evaluate('''look => {
perfView(look, 6);
const now = performance.now.bind(performance);
performance.now = () => 5000;
R.rain = rainAt(Math.max(0, Math.min(W - 1, Math.floor(R.camX + R.bw / 2))));
try {
PERF_MARKS = false; drawWorld(); const a = R.canvas.toDataURL();
PERF_MARKS = true; drawWorld(); const b = R.canvas.toDataURL();
return a === b;
} finally { PERF_MARKS = false; performance.now = now; }
}''', look)
assert identical, f'{look}: marks changed pixels'
pg.evaluate('''() => {
SET.showPerf = true; GAME.paused = false;
for (let k = 0; k < 30; k++) frame(lastT + 1000 / 60);
GAME.paused = true;
$('cards').innerHTML = '';
document.querySelectorAll('.toast').forEach(e => e.remove());
}''')
text = pg.locator('#perf').inner_text()
assert all(word in text for word in ['world', 'light', 'overlays', 'move', 'heat', 'rest']), text
pg.screenshot(path=f'/tmp/t88-perf-{look}.png')
assert not errors, errors
browser.close()
print('PASS headers, quantiles, comparison, deterministic simulation and pixel/smooth image parity; readout screenshots saved')
if __name__ == '__main__':
main()