feedback7 G-TOOLS: add the battle performance bench under public/dev (PR #198 by @Cloudnyco)
Applied as-is. Captured real battles live in public/dev/perf (including the round-13 联防 on the wide round battlefield), with a browser speed page and a headless benchmark. public/dev stays out of the package. The Chrome benchmark was not run. The numbers in the original pull request are from 0.1.4 and are not quoted here. Tests: node --test test/perfbench.test.js test/render/battle-perf.browser.test.js — 13 pass, 0 fail; the Chrome suite skipped (RENDER_E2E unset).
This commit is contained in:
1 parent
1f147a288f
commit
e8ba091298
13 files changed
+984
No files matched your search
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
// tools/capture-specs.mjs — capture real BattleSpecs for the battle perf page (public/dev/battle-perf.html) and
|
||||
// tools/perfbench.mjs: the b.start a browser receives under client-side combat (DESIGN §14), taken from a headless bot
|
||||
// match (virtual time, real simulation, like tools/matchrun.mjs). Co-op with 4 seats: the first two are humans on AI
|
||||
// autoplay (so the match sends b.start to seat 1, whose battles are kept) and two bots; every player starts with a high
|
||||
// LP so the match reaches the Final Assault and the Hidden Core. By default the last normal round, the last 联防 field
|
||||
// of seat 1, the Final Assault and the Hidden Core are written:
|
||||
//
|
||||
// <out>/<difficulty>-<kind>-r<round>.json the b.start message (elapsed / clocks zeroed, authoritative)
|
||||
// <out>/index.json [{ name, title, kind, round, stageId, players, units }]
|
||||
//
|
||||
// The match is deterministic for a seed, so a plain run reproduces the committed specs byte for byte as long as the
|
||||
// game data and the bots do not change.
|
||||
//
|
||||
// Usage: node tools/capture-specs.mjs [--difficulty HARD] [--seed 7] [--out public/dev/perf] [--all]
|
||||
// --all write every battle seat 1 played, not only the four defaults
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Match } from '../server/match/Match.js';
|
||||
import { VirtualScheduler } from '../server/match/scheduler.js';
|
||||
import { getData } from '../server/data.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const KIND_TITLE = { normal: '普通战', unite: '联防', boss: '最终攻势', hidden: '隐秘核心' };
|
||||
|
||||
/** One spec's file name and index row. */
|
||||
export function specMeta(difficulty, msg, round) {
|
||||
const spec = msg.spec || {};
|
||||
const players = Array.isArray(spec.players) ? spec.players : [];
|
||||
return {
|
||||
name: `${difficulty.toLowerCase()}-${msg.kind}-r${round}`,
|
||||
title: `${KIND_TITLE[msg.kind] || msg.kind} · 第 ${round} 回合`,
|
||||
kind: msg.kind, round, stageId: spec.stageId,
|
||||
players: players.length,
|
||||
units: players.reduce((n, p) => n + (Array.isArray(p?.units) ? p.units.length : 0), 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** The defaults: the last normal round, the last 联防, the Final Assault and the Hidden Core (latest send of each). */
|
||||
export function pickDefaults(captured) {
|
||||
const last = new Map();
|
||||
for (const c of captured) last.set(`${c.msg.kind}-r${c.round}`, c);
|
||||
const all = [...last.values()];
|
||||
const latest = (kind) => all.filter((c) => c.msg.kind === kind).sort((a, b) => b.round - a.round)[0];
|
||||
return ['normal', 'unite', 'boss', 'hidden'].map(latest).filter(Boolean);
|
||||
}
|
||||
|
||||
/** A b.start as the perf page starts it: from the beginning, authoritative, no clocks of the capturing run. */
|
||||
export function cleanStart(msg) {
|
||||
return { ...msg, elapsed: 0, startAt: 0, serverNow: 0, authoritative: true, watch: false, done: false };
|
||||
}
|
||||
|
||||
/** Run one bot match and return seat 1's b.start messages in order: [{ round, msg }]. */
|
||||
export function captureMatch({ difficulty = 'HARD', seed = 7, data = getData({ log: { warn() {}, error() {}, info() {} } }) } = {}) {
|
||||
const sched = new VirtualScheduler();
|
||||
const seats = [0, 1, 2, 3].map((i) => ({ seat: i, playerId: i < 2 ? `p_${i}` : `ai_${i}`, name: i < 2 ? `P${i + 1}` : `AI-${i + 1}`, isBot: i >= 2, connected: true }));
|
||||
const captured = [];
|
||||
let summary = null;
|
||||
const m = new Match({
|
||||
roomCode: 'PERF', mode: 'coop', difficulty, seats, seed, data, scheduler: sched, clientCombat: true,
|
||||
log: { info() {}, debug() {}, warn() {}, error() {} },
|
||||
send: (pid, msg) => {
|
||||
if (pid === 'p_0' && msg && msg.t === 'b.start' && !msg.watch) captured.push({ round: m.round, msg: JSON.parse(JSON.stringify(msg)) });
|
||||
return true;
|
||||
},
|
||||
broadcast: () => {}, onEnd: (s) => { summary = s; },
|
||||
});
|
||||
for (const ps of m.players.values()) if (!ps.isBot) ps.autoplay = true;
|
||||
let lpSet = false;
|
||||
m.start();
|
||||
sched.runUntil(() => {
|
||||
if (!lpSet && m.phase === 'PREP' && m.round === 1) { for (const ps of m.players.values()) ps.lp = 400; lpSet = true; }
|
||||
return summary != null;
|
||||
}, { maxSteps: 5e6 });
|
||||
m.dispose();
|
||||
return { captured, summary };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const opt = (k, d) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? argv[i + 1] : d; };
|
||||
const difficulty = String(opt('difficulty', 'HARD')).toUpperCase();
|
||||
const seed = Number(opt('seed', 7));
|
||||
const out = path.resolve(ROOT, opt('out', 'public/dev/perf'));
|
||||
const { captured, summary } = captureMatch({ difficulty, seed });
|
||||
if (!summary) { console.error('the match did not end'); return 1; }
|
||||
const keep = argv.includes('--all') ? [...new Map(captured.map((c) => [`${c.msg.kind}-r${c.round}`, c])).values()] : pickDefaults(captured);
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const index = [];
|
||||
for (const c of keep) {
|
||||
const meta = specMeta(difficulty, c.msg, c.round);
|
||||
fs.writeFileSync(path.join(out, `${meta.name}.json`), JSON.stringify(cleanStart(c.msg)));
|
||||
index.push(meta);
|
||||
console.log(`${meta.name.padEnd(18)} ${meta.title} stage ${meta.stageId} players ${meta.players} units ${meta.units}`);
|
||||
}
|
||||
fs.writeFileSync(path.join(out, 'index.json'), `${JSON.stringify(index, null, 1)}\n`);
|
||||
console.log(`${index.length} specs → ${path.relative(ROOT, out)} (match: ${summary.reason}, rounds ${summary.roundsPassed})`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main();
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env node
|
||||
// tools/perfbench.mjs — frame-time benchmark of real battles in headless Chrome (public/dev/battle-perf.html: the battle
|
||||
// runner + sim + field view, as in a match) for every spec × quality × CPU slowdown. Chrome DevTools CPU throttling
|
||||
// stands in for slower phones (4× ≈ mid-range, 6× ≈ low-end — an approximation: no phone GPU, thermals or Safari).
|
||||
// With --profile, a CPU profile of each sample is summarised by source area, by function (self time) and by pipeline
|
||||
// stage (inclusive time: Spine pose, impostor redraw, Pixi render, three.js board, battle runner, sim, …).
|
||||
//
|
||||
// Usage: CHROME_PATH=<chrome> node tools/perfbench.mjs [--specs a,b] [--quality high,low] [--cpu 1,4,6] [--warm 20]
|
||||
// [--sample 6] [--width 844] [--height 390] [--dpr 2] [--profile] [--out test/e2e/out/perf] [--json]
|
||||
// --specs names of public/dev/perf/index.json (default: all) --warm seconds before sampling (default 20)
|
||||
// --sample seconds per sample (default 6) --profile also record and summarise a CPU profile
|
||||
// Needs the downloaded assets (public/assets: real Spine models; without them every unit is a diamond and the numbers
|
||||
// mean little). Writes <out>/results.json (+ <spec>-<quality>-<cpu>x.cpuprofile / .summary.json with --profile).
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const DEFAULT_CHROME = {
|
||||
win32: 'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
darwin: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
linux: '/usr/bin/google-chrome',
|
||||
};
|
||||
|
||||
/** GPU flags for headless Chrome per platform (ANGLE backend; Metal exists on macOS only, D3D11 on Windows only). */
|
||||
export function gpuArgs(platform = process.platform) {
|
||||
const angle = platform === 'win32' ? ['--use-angle=d3d11'] : platform === 'darwin' ? ['--use-angle=metal'] : [];
|
||||
return ['--no-first-run', '--enable-gpu', '--ignore-gpu-blocklist', ...angle];
|
||||
}
|
||||
|
||||
/** Source area of a profile call frame (vendor libraries, the game's own modules, the browser's own work). */
|
||||
export function areaOf(cf) {
|
||||
const url = cf.url || '';
|
||||
if (!url) {
|
||||
if (cf.functionName === '(garbage collector)') return 'gc';
|
||||
if (cf.functionName === '(idle)') return 'idle';
|
||||
if (cf.functionName === '(program)') return 'program';
|
||||
return 'native';
|
||||
}
|
||||
if (/\/sim\//.test(url)) return 'sim';
|
||||
if (/\/js\/battle\//.test(url)) return 'runner';
|
||||
if (/\/js\/render\//.test(url)) return 'render';
|
||||
if (/\/js\/(ui|screens)\//.test(url)) return 'ui';
|
||||
if (/pixi-spine/.test(url)) return 'spine';
|
||||
if (/pixi(\.min)?\.js/.test(url)) return 'pixi';
|
||||
if (/three\./.test(url)) return 'three';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/** Pipeline stages measured inclusively (a sample counts once per stage on its stack). */
|
||||
export const STAGES = [
|
||||
['spinePose', 'Spine pose (actor.update)', (cf) => cf.functionName === 'update' && /render\/spine\.js/.test(cf.url)],
|
||||
['impostorFlush', 'impostor redraw (skeletons → atlas)', (cf) => cf.functionName === 'flush' && /render\/impostor\.js/.test(cf.url)],
|
||||
['unitUpdate', 'unit views (units.js update)', (cf) => cf.functionName === 'update' && /render\/units\.js/.test(cf.url)],
|
||||
['frameBody', 'view frame (all JS before the Pixi render)', (cf) => cf.functionName === 'frameBody' && /render\/app\.js/.test(cf.url)],
|
||||
['pixiRender', 'Pixi render (main + render textures)', (cf) => cf.functionName === 'render' && /pixi(\.min)?\.js/.test(cf.url)],
|
||||
['board3d', 'three.js board render', (cf) => cf.functionName === 'render' && /board3d\/scene\.js/.test(cf.url)],
|
||||
['runner', 'battle runner frame', (cf) => cf.functionName === 'frame' && /js\/battle\/runner\.js/.test(cf.url)],
|
||||
['sim', 'sim (any /sim/ module)', (cf) => /\/sim\//.test(cf.url || '')],
|
||||
['gc', 'garbage collector', (cf) => cf.functionName === '(garbage collector)'],
|
||||
['program', 'browser-native (program)', (cf) => cf.functionName === '(program)'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Summary of a CDP CPU profile ({ nodes, samples, timeDeltas }): busy time (idle excluded), the share of each source
|
||||
* area (self time), the top functions by self time, and each STAGES entry's inclusive share.
|
||||
*/
|
||||
export function aggregateProfile(profile, top = 30) {
|
||||
const nodes = new Map(profile.nodes.map((n) => [n.id, n]));
|
||||
const parent = new Map();
|
||||
for (const n of profile.nodes) for (const c of n.children || []) parent.set(c, n.id);
|
||||
const area = new Map(), fn = new Map(), stage = new Map(STAGES.map(([k]) => [k, 0]));
|
||||
let busy = 0;
|
||||
for (let i = 0; i < profile.samples.length; i++) {
|
||||
const dt = profile.timeDeltas[i] || 0;
|
||||
const leaf = nodes.get(profile.samples[i]);
|
||||
if (!leaf) continue;
|
||||
const a = areaOf(leaf.callFrame);
|
||||
if (a === 'idle') continue;
|
||||
busy += dt;
|
||||
area.set(a, (area.get(a) || 0) + dt);
|
||||
const cf = leaf.callFrame;
|
||||
const key = `${cf.functionName || '(anonymous)'} ${cf.url ? `${cf.url.replace(/^[a-z]+:\/\/[^/]+/, '')}:${cf.lineNumber + 1}` : ''}`.trim();
|
||||
fn.set(key, (fn.get(key) || 0) + dt);
|
||||
const hit = new Set();
|
||||
for (let id = profile.samples[i]; id != null; id = parent.get(id)) {
|
||||
const f = nodes.get(id).callFrame;
|
||||
for (const [k, , test] of STAGES) if (!hit.has(k) && test(f)) hit.add(k);
|
||||
}
|
||||
for (const k of hit) stage.set(k, stage.get(k) + dt);
|
||||
}
|
||||
const pct = (us) => (busy > 0 ? Math.round((us / busy) * 1000) / 10 : 0);
|
||||
const ms = (us) => Math.round(us / 100) / 10;
|
||||
return {
|
||||
busyMs: ms(busy),
|
||||
areas: [...area].sort((x, y) => y[1] - x[1]).map(([name, us]) => ({ area: name, ms: ms(us), pct: pct(us) })),
|
||||
stages: STAGES.map(([k, label]) => ({ stage: k, label, ms: ms(stage.get(k)), pct: pct(stage.get(k)) })),
|
||||
top: [...fn].sort((x, y) => y[1] - x[1]).slice(0, top).map(([name, us]) => ({ fn: name, ms: ms(us), pct: pct(us) })),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const opt = (k, d) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? argv[i + 1] : d; };
|
||||
const list = (k, d) => String(opt(k, d)).split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const index = JSON.parse(fs.readFileSync(path.join(ROOT, 'public/dev/perf/index.json'), 'utf8'));
|
||||
const specs = argv.includes('--specs') ? list('specs', '') : index.map((s) => s.name);
|
||||
const qualities = list('quality', 'high');
|
||||
const cpus = list('cpu', '1,4,6').map(Number);
|
||||
const warm = Number(opt('warm', 20)), sample = Number(opt('sample', 6));
|
||||
const width = Number(opt('width', 844)), height = Number(opt('height', 390)), dpr = Number(opt('dpr', 2));
|
||||
const profileOn = argv.includes('--profile');
|
||||
const out = path.resolve(ROOT, opt('out', 'test/e2e/out/perf'));
|
||||
const chrome = process.env.CHROME_PATH || DEFAULT_CHROME[process.platform];
|
||||
if (!chrome || !fs.existsSync(chrome)) { console.error(`Chrome not found (${chrome}); set CHROME_PATH`); return 2; }
|
||||
if (!fs.existsSync(path.join(ROOT, 'public/assets/spine'))) console.warn('warning: public/assets/spine is missing (npm run setup): units draw as diamonds');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
const { startServer } = await import('../server/index.js');
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: gpuArgs() });
|
||||
const results = [];
|
||||
try {
|
||||
for (const spec of specs) for (const quality of qualities) for (const cpu of cpus) {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.setViewport({ width, height, deviceScaleFactor: dpr });
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/dev/battle-perf.html?spec=${encodeURIComponent(spec)}&quality=${quality}&panel=0`);
|
||||
await page.waitForFunction('window.__perf && (window.__perf.ready || window.__perf.error)', { timeout: 60000 });
|
||||
const err = await page.evaluate(() => globalThis.__perf.error);
|
||||
if (err) { console.log(`${spec}: ${err.split('\n')[0]}`); await page.close(); continue; }
|
||||
const cdp = await page.createCDPSession();
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: cpu });
|
||||
await new Promise((r) => setTimeout(r, warm * 1000));
|
||||
if (profileOn) {
|
||||
await cdp.send('Profiler.enable');
|
||||
await cdp.send('Profiler.setSamplingInterval', { interval: 200 });
|
||||
await cdp.send('Profiler.start');
|
||||
}
|
||||
const s = await page.evaluate((ms) => globalThis.__perf.sample(ms), sample * 1000);
|
||||
let summary = null;
|
||||
if (profileOn) {
|
||||
const { profile } = await cdp.send('Profiler.stop');
|
||||
summary = aggregateProfile(profile);
|
||||
const base = path.join(out, `${spec}-${quality}-${cpu}x`);
|
||||
fs.writeFileSync(`${base}.cpuprofile`, JSON.stringify(profile));
|
||||
fs.writeFileSync(`${base}.summary.json`, JSON.stringify(summary, null, 1));
|
||||
}
|
||||
const rec = { spec, quality, cpu, ...s, profiled: profileOn, errors: errors.slice(0, 3) };
|
||||
results.push(rec);
|
||||
if (!argv.includes('--json')) {
|
||||
console.log(`${spec.padEnd(18)} ${quality.padEnd(6)} ${String(cpu).padStart(2)}x fps ${String(s.fps).padStart(6)} p95 ${String(s.p95).padStart(6)} ms >33ms ${String(s.over33).padStart(5)}%`
|
||||
+ ` sim ${s.simMsPerTick} ms/tick view CPU ${s.viewCpuMs} ms units ${s.units} load ${s.lod}${errors.length ? ` ERRORS ${errors.length}` : ''}`);
|
||||
if (summary) console.log(` ${summary.stages.filter((x) => x.pct > 0).map((x) => `${x.stage} ${x.pct}%`).join(' · ')}`);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
fs.writeFileSync(path.join(out, 'results.json'), JSON.stringify(results, null, 1));
|
||||
await browser.close();
|
||||
await srv.close();
|
||||
}
|
||||
if (argv.includes('--json')) console.log(JSON.stringify(results));
|
||||
return results.some((r) => r.errors.length) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().then((code) => { process.exitCode = code; }, (err) => { console.error(err); process.exitCode = 1; });
|
||||
}
|
||||
Reference in new issue
Block a user