Files
aego/tools/websmoke/platformer-lighting.mjs
sanspie a6822156cd
ci-platforms / windows amd64 smoke (push) Skipped
ci / release (push) Successful in 8m58s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 4m46s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m28s
Finish Stage 12 editor fixes and refreshed platformer
2026-11-05 17:36:42 +03:00

233 lines
15 KiB
JavaScript

// The canonical platformer release export, with the recorded full-level input.
// This is a functional software-GPU replay/capture, not a hardware perf claim.
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import crypto from "node:crypto";
import { releaseBloom } from "./release-bloom.mjs";
import { releaseScenario } from "./release-scenarios.mjs";
import { PNG } from "pngjs";
const { chromium, firefox } = await import(process.env.PLAYWRIGHT_MODULE ?? "playwright-core");
const opts = Object.fromEntries(process.argv.slice(2).map(arg => {
const at = arg.indexOf("=");
return at < 0 ? [arg.replace(/^--/, ""), "true"] : [arg.slice(2, at), arg.slice(at + 1)];
}));
const scenario = opts.scenario ?? "lighting";
if (!["lighting", "finish", "restore", "topdown", "bloom"].includes(scenario)) throw new Error("unknown release scenario: " + scenario);
const root = path.resolve(opts.root ?? "../../.cache/lighting/platformer/web");
const out = path.resolve(opts.out ?? "../../.cache/lighting/platformer/browser");
const replayPath = path.resolve(opts.replay ?? "../../tests/integration/testdata/platformer_lighting_route.aeinput");
const route = fs.readFileSync(replayPath, "utf8");
const hash = bytes => crypto.createHash("sha256").update(bytes).digest("hex");
const manifest = fs.readFileSync(path.join(root, "export.aetext"), "utf8");
if (scenario === "topdown" && !/\n module="topdown"/.test(manifest)) throw new Error("topdown scenario requires a topdown export");
const listing = JSON.parse(fs.readFileSync(path.join(root, "data/files.json"), "utf8"));
fs.mkdirSync(out, { recursive: true });
const report = {
project: scenario === "topdown" ? "examples/topdown" : "examples/platformer", scenario, root, routeSHA256: scenario === "topdown" ? undefined : hash(route), seed: 42,
manifestSHA256: hash(fs.readFileSync(path.join(root, "data/manifest.aetext"))),
exportHash: manifest.match(/\bhash="([a-f0-9]+)"/)?.[1],
wasmSHA256: hash(fs.readFileSync(path.join(root, "game.wasm"))),
wasmBytes: listing.wasm, wasmGzipBytes: listing.wasmGzip,
viewport: [1280, 720], DPR: 1, browsers: [],
environment: "functional software GPU; paused exact-frame captures; no performance timing claim",
};
// Only the registered presentation action changes between paired replays.
// Move/Jump/Attack and all gameplay action records remain byte-identical.
function disabledRoute(source) {
const lines = source.trimEnd().split("\n");
const actions = lines.find(line => line.startsWith("actions ")).split(" ").slice(1);
const index = actions.indexOf("LightingToggle");
if (index < 0) throw new Error("canonical route has no registered LightingToggle action");
const header = lines.filter(line => !line.startsWith("s "));
const samples = lines.filter(line => line.startsWith("s "));
samples.push(`s 1 ${index} 3 3f800000 0`, `s 2 ${index} 4 0 0`);
samples.sort((a, b) => {
const aa = a.split(" "), bb = b.split(" ");
return +aa[1] - +bb[1] || +aa[2] - +bb[2];
});
return [...header, ...samples, ""].join("\n");
}
// Hold only requestAnimationFrame delivery. WebGL, its restoration events and
// the actual exported WASM keep running normally. No scene state is patched.
function exactFrames(replay) {
const canonicalStart = typeof replay === "object";
globalThis.aegoReplay = canonicalStart ? replay.data : replay;
const original = requestAnimationFrame.bind(window);
const state = globalThis.__lightingReplay = { hold: false, held: [] };
window.requestAnimationFrame = callback => {
// The production player initializes at frame 1, tick 0. From there
// request the public fixed clock for the deterministic input replay.
if (canonicalStart && globalThis.aegoGame?.frames() === 1 && !state.firstFrame) {
state.firstFrame = JSON.parse(aegoGame.stats());
aegoGame.lockstep(true);
}
// Game.Window is signalled by Update; the actual settings menu pauses
// Update. A menu probe stops after presentation instead.
if (state.menuFrame && globalThis.aegoGame?.frames() >= state.menuFrame && aegoGame.menuOpen()) {
state.hold = true;
state.menuReady = true;
}
return state.hold ? (state.held.push(callback), 0) : original(callback);
};
const resume = () => {
state.hold = false;
for (const callback of state.held.splice(0)) original(callback);
};
state.advanceToOpenMenu = target => { state.menuFrame = target; resume(); };
state.advance = target => {
const game = globalThis.aegoGame;
const from = game.frames() + 1;
if (!game.window(from, target, () => {}, () => { state.hold = true; })) throw new Error("capture target is not ahead of the current frame");
resume();
};
}
const mime = { ".html": "text/html", ".js": "text/javascript", ".wasm": "application/wasm", ".json": "application/json" };
const server = http.createServer((req, res) => {
const relative = decodeURIComponent(new URL(req.url, "http://localhost").pathname).replace(/^\/+/, "") || "index.html";
const file = path.resolve(root, relative);
if (!file.startsWith(root + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
res.writeHead(404); res.end(); return;
}
res.writeHead(200, { "Content-Type": mime[path.extname(file)] ?? "application/octet-stream" });
fs.createReadStream(file).pipe(res);
});
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
const base = `http://127.0.0.1:${server.address().port}`;
const stats = page => page.evaluate(() => JSON.parse(globalThis.aegoGame.stats()));
async function advance(page, target, timeout = 180000) {
await page.evaluate(t => globalThis.__lightingReplay.advance(t), target);
await page.waitForFunction(t => globalThis.__lightingReplay.hold && globalThis.aegoGame.frames() === t, target, { timeout, polling: 100 });
}
async function capture(page, filename) {
const bytes = await page.screenshot({ clip: { x: 0, y: 0, width: 1280, height: 720 } });
fs.writeFileSync(path.join(out, filename), bytes);
const png = PNG.sync.read(bytes);
const colors = new Set();
for (let i = 0; i < png.data.length; i += 32) colors.add(png.data.readUInt32LE(i));
if (colors.size < 16) throw new Error(`${filename} is empty or uniform`);
return { file: filename, pixelSHA256: hash(png.data), sampledColors: colors.size, stats: await stats(page) };
}
try {
for (const name of (opts.browsers ?? "chromium,firefox").split(",")) {
if (!["chromium", "firefox"].includes(name)) throw new Error(`unknown browser ${name}`);
const isFirefox = name === "firefox";
const result = { browser: name, passed: [], failures: [], captures: [], modes: [] };
report.browsers.push(result);
let browser;
try {
browser = await (isFirefox ? firefox : chromium).launch({
executablePath: isFirefox ? process.env.FIREFOX : process.env.CHROMIUM ?? "/usr/bin/chromium",
headless: !isFirefox || !process.env.DISPLAY,
args: isFirefox ? [] : ["--no-sandbox", "--mute-audio", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"],
firefoxUserPrefs: isFirefox ? { "webgl.force-enabled": true, "webgl.disabled": false } : undefined,
});
result.version = browser.version();
if (scenario === "bloom") {
await releaseBloom(browser, base, result, report, route, exactFrames, advance, capture, out);
result.passed.push("authored Bloom around the lantern and byte-identical menu interior at matched simulation ticks");
continue;
}
if (scenario !== "lighting") {
const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
try {
const page = await context.newPage();
page.on("pageerror", error => result.failures.push(error.message));
page.on("console", m => {
if (!result.console) result.console = [];
if (result.console.length < 300) result.console.push(m.type() + ": " + m.text().slice(0, 1500));
});
if (scenario === "finish") await page.addInitScript(exactFrames, { data: Buffer.from(route).toString("base64") });
await releaseScenario(scenario, page, base, result, advance, capture);
if (result.served["/game.wasm"] !== report.wasmSHA256 || result.served["/data/manifest.aetext"] !== report.manifestSHA256) {
throw new Error("browser response bytes differ from the selected release");
}
result.passed.push(scenario);
} finally { await context.close(); }
continue;
}
for (const enabled of [true, false]) {
const mode = enabled ? "on" : "off";
const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
try {
const page = await context.newPage();
page.on("pageerror", error => result.failures.push(`${mode}: ${error.message}`));
page.on("console", message => {
if (process.env.TRACE_WEBGL) console.error(`${name} ${mode} ${message.type()}: ${message.text()}`);
if (message.type() === "error" || /\bERROR\b|panic:|WebGL:.*INVALID_/.test(message.text())) result.failures.push(`${mode}: ${message.text()}`);
});
page.on("requestfailed", req => result.failures.push(`${mode}: ${req.url()} ${req.failure()?.errorText}`));
await page.addInitScript(exactFrames, Buffer.from(enabled ? route : disabledRoute(route)).toString("base64"));
await page.goto(`${base}/?seed=42&bench=1`);
console.log(`${name} ${mode}: export loaded; waiting for runtime`);
try {
await page.waitForFunction(() => {
const error = document.getElementById("error");
if (error && !error.hidden) throw new Error(error.textContent);
return globalThis.aegoGame;
}, null, { timeout: 120000, polling: 100 });
} catch (error) {
const state = await page.evaluate(() => ({ status: document.getElementById("status")?.textContent, error: document.getElementById("error")?.textContent, replay: globalThis.__lightingReplay, boot: globalThis.aegoBoot }));
throw new Error(`${error.message}; boot state ${JSON.stringify(state)}`);
}
console.log(`${name} ${mode}: runtime ready at frame ${await page.evaluate(() => globalThis.aegoGame.frames())}`);
await page.evaluate(() => {
globalThis.aegoGame.lockstep(true);
document.getElementById("fullscreen").style.display = "none";
document.getElementById("mute").style.display = "none";
});
for (const frame of [400, 1039, 1216, 1840]) {
console.log(`${name} ${mode}: replay to ${frame}`);
await advance(page, frame);
const c = await capture(page, `${name}-${mode}-${String(frame).padStart(4, "0")}.png`);
if (c.stats.frames !== frame || c.stats.ticks < frame - 1) throw new Error(`unexpected replay clock at ${frame}`);
result.renderer = c.stats.renderer;
result.captures.push({ lighting: enabled, frame, ...c });
console.log(`${name} ${mode} frame=${frame} ticks=${c.stats.ticks} pixels=${c.pixelSHA256.slice(0, 12)}`);
}
if (enabled) {
await page.evaluate(() => {
globalThis.__contextEvents = { lost: 0, restored: 0 };
const canvas = document.getElementById("game");
canvas.addEventListener("webglcontextlost", () => globalThis.__contextEvents.lost++);
canvas.addEventListener("webglcontextrestored", () => globalThis.__contextEvents.restored++);
});
if (!await page.evaluate(() => globalThis.aegoGame.loseContext(true))) throw new Error("WEBGL_lose_context unavailable");
await page.waitForFunction(() => globalThis.__contextEvents.lost === 1, null, { timeout: 10000 });
await page.evaluate(() => globalThis.aegoGame.loseContext(false));
await page.waitForFunction(() => globalThis.__contextEvents.restored === 1, null, { timeout: 30000 });
await advance(page, 1900);
result.restored = await capture(page, `${name}-restored-1900.png`);
if (result.restored.stats.lost || result.restored.stats.restores < 1) throw new Error("context resources did not restore");
result.passed.push("real context loss/restored event and continued canonical replay");
}
result.modes.push({ mode, end: await stats(page) });
} finally { await context.close(); }
}
for (const frame of [400, 1039, 1216, 1840]) {
const a = result.captures.find(c => c.frame === frame && c.lighting);
const b = result.captures.find(c => c.frame === frame && !c.lighting);
if (!a || !b || a.pixelSHA256 === b.pixelSHA256) throw new Error(`lighting did not change pixels at frame ${frame}`);
}
result.passed.push("same exported assets and gameplay input with registered LightingToggle presentation action");
result.passed.push("exact 400/1039/1216/1840 native-compatible replay clocks and nonempty rendered images");
} catch (error) {
result.failures.push(error.stack ?? String(error));
console.error(`${name}: ${error.stack ?? error}`);
} finally {
await browser?.close();
console.log(`${name} ${scenario}: ${result.failures.length ? "FAIL" : "PASS"}`);
fs.writeFileSync(path.join(out, "results.json"), JSON.stringify(report, null, 2) + "\n");
}
}
} finally {
await new Promise(resolve => server.close(resolve));
fs.writeFileSync(path.join(out, "results.json"), JSON.stringify(report, null, 2) + "\n");
}
if (report.browsers.some(browser => browser.failures.length)) process.exitCode = 1;