feat: complete native client rendering parity updates
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env node
|
||||
// Run the macOS native client with a real server or the local fake server.
|
||||
// A real-server run remains unverified until a person checks the in-game flows.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { spawn, execFile } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { redact, secretLiterals } from './redact_stream.mjs';
|
||||
|
||||
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const defaults = {
|
||||
binary: path.join(repo, 'build-release/native_render/mt_native_render'),
|
||||
client: path.join(repo, 'Client'),
|
||||
output: path.join(repo, 'build/native-acceptance', new Date().toISOString().replaceAll(':', '-')),
|
||||
};
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage: node script/native_mac_acceptance.mjs (--server HOST:AUTH_PORT:GAME_PORT | --fake) [options]
|
||||
--binary FILE Native live-client binary (default: build-release/native_render/mt_native_render)
|
||||
--client DIR 40250 Client directory (default: Client)
|
||||
--output DIR New evidence directory (default: build/native-acceptance/<timestamp>)
|
||||
--min-seconds N Minimum real-server observation time (default: 1800)
|
||||
--frames N Fake-server smoke frames (default: 180; fake mode only)
|
||||
--help Show this help
|
||||
|
||||
Real-server mode opens the login screen. Enter credentials in the app, exercise
|
||||
the checklist in the report, then close the window. Credentials are never
|
||||
passed on the command line. The report requires manual review before PASS.`);
|
||||
}
|
||||
|
||||
function optionsOf(argv) {
|
||||
const options = { ...defaults, minSeconds: 1800, frames: 180 };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const key = argv[i];
|
||||
if (key === '--help') { options.help = true; continue; }
|
||||
if (!['--server', '--fake', '--binary', '--client', '--output', '--min-seconds', '--frames'].includes(key))
|
||||
throw new Error(`Unknown option: ${key}`);
|
||||
if (key === '--fake') { options.fake = true; continue; }
|
||||
if (++i >= argv.length) throw new Error(`Missing value for ${key}`);
|
||||
const field = { '--server': 'server', '--binary': 'binary', '--client': 'client',
|
||||
'--output': 'output', '--min-seconds': 'minSeconds', '--frames': 'frames' }[key];
|
||||
options[field] = argv[i];
|
||||
}
|
||||
if (!options.help) {
|
||||
if (Boolean(options.server) === Boolean(options.fake)) throw new Error('Choose exactly one of --server or --fake');
|
||||
for (const field of ['minSeconds', 'frames']) {
|
||||
options[field] = Number(options[field]);
|
||||
if (!Number.isInteger(options[field]) || options[field] < (field === 'frames' ? 1 : 0))
|
||||
throw new Error(`Invalid ${field}`);
|
||||
}
|
||||
if (options.server && argv.includes('--frames')) throw new Error('--frames is only for fake-server smoke');
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function parseServer(spec) {
|
||||
const match = /^([^:\s]+):([0-9]+):([0-9]+)$/.exec(spec);
|
||||
if (!match || [match[2], match[3]].some((value) => +value < 1 || +value > 65535))
|
||||
throw new Error('Server must be HOST:AUTH_PORT:GAME_PORT (one shared host)');
|
||||
return { host: match[1], authPort: +match[2], gamePort: +match[3] };
|
||||
}
|
||||
|
||||
function reachable(host, port) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host, port });
|
||||
socket.setTimeout(3000);
|
||||
socket.once('connect', () => { socket.destroy(); resolve(true); });
|
||||
socket.once('error', () => { socket.destroy(); resolve(false); });
|
||||
socket.once('timeout', () => { socket.destroy(); resolve(false); });
|
||||
});
|
||||
}
|
||||
|
||||
function readRss(pid) {
|
||||
return new Promise((resolve) => execFile('ps', ['-o', 'stat=,rss=', '-p', String(pid)],
|
||||
{ timeout: 3000 }, (error, stdout) => {
|
||||
if (error) return resolve(null);
|
||||
const [state, value] = stdout.trim().split(/\s+/);
|
||||
const rss = Number(value);
|
||||
resolve(state?.startsWith('Z') || !Number.isInteger(rss) || rss <= 0 ? null : rss);
|
||||
}));
|
||||
}
|
||||
|
||||
function parseSummary(log) {
|
||||
const line = log.trim().split('\n').reverse().find((row) => row.startsWith('device=') && row.includes(' frames='));
|
||||
if (!line) return null;
|
||||
const values = {};
|
||||
for (const match of line.matchAll(/(?:^| )([a-z0-9_]+)=([^ ]+)/g)) {
|
||||
const number = Number(match[2]);
|
||||
values[match[1]] = Number.isFinite(number) ? number : match[2];
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let options;
|
||||
try { options = optionsOf(process.argv.slice(2)); }
|
||||
catch (error) { console.error(error.message); usage(); return 2; }
|
||||
if (options.help) { usage(); return 0; }
|
||||
if (process.platform !== 'darwin') { console.error('This acceptance runner targets macOS'); return 2; }
|
||||
if (!fs.existsSync(options.binary) || !fs.existsSync(path.join(path.dirname(options.binary), 'python27.zip'))
|
||||
|| !fs.existsSync(path.join(options.client, 'pack/Index'))) {
|
||||
console.error('Missing native live-client binary, adjacent python27.zip, or Client/pack/Index');
|
||||
return 2;
|
||||
}
|
||||
if (fs.existsSync(options.output)) { console.error(`Output already exists: ${options.output}`); return 2; }
|
||||
let server = null;
|
||||
if (options.server) {
|
||||
try { server = parseServer(options.server); }
|
||||
catch (error) { console.error(error.message); return 2; }
|
||||
for (const port of [server.authPort, server.gamePort]) {
|
||||
if (!await reachable(server.host, port)) {
|
||||
console.error(`Server endpoint unreachable: ${server.host}:${port}; client not started`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(options.output, { recursive: true, mode: 0o700 });
|
||||
const logPath = path.join(options.output, 'client.log');
|
||||
const rssPath = path.join(options.output, 'rss.jsonl');
|
||||
const logFile = fs.openSync(logPath, 'w', 0o600);
|
||||
const rssFile = fs.openSync(rssPath, 'w', 0o600);
|
||||
const args = ['--live-client', options.client];
|
||||
if (server) args.push('--live-server', options.server, '--login-screen');
|
||||
else args.push('--fake-mobs', '24', '--frames', String(options.frames));
|
||||
const started = Date.now();
|
||||
const child = spawn(options.binary, args, { cwd: repo, stdio: ['inherit', 'pipe', 'pipe'] });
|
||||
const literals = secretLiterals();
|
||||
let log = '';
|
||||
let pending = '';
|
||||
const append = (chunk) => {
|
||||
pending += chunk.toString('utf8');
|
||||
const lines = pending.split('\n');
|
||||
pending = lines.pop();
|
||||
for (const line of lines) {
|
||||
const clean = `${redact(line, literals)}\n`;
|
||||
fs.writeSync(logFile, clean);
|
||||
process.stdout.write(clean);
|
||||
log += clean;
|
||||
}
|
||||
};
|
||||
child.stdout.on('data', append);
|
||||
child.stderr.on('data', append);
|
||||
let sampleBusy = false;
|
||||
let pendingSample = Promise.resolve();
|
||||
const samples = [];
|
||||
const sample = async () => {
|
||||
if (sampleBusy) return;
|
||||
sampleBusy = true;
|
||||
const rss = await readRss(child.pid);
|
||||
if (rss !== null) {
|
||||
const row = { wall_ms: Date.now(), rss_kib: rss };
|
||||
samples.push(row);
|
||||
fs.writeSync(rssFile, `${JSON.stringify(row)}\n`);
|
||||
}
|
||||
sampleBusy = false;
|
||||
};
|
||||
await sample();
|
||||
const interval = setInterval(() => { pendingSample = sample(); }, 1000);
|
||||
const onSignal = (signal) => child.kill(signal);
|
||||
process.on('SIGINT', onSignal);
|
||||
process.on('SIGTERM', onSignal);
|
||||
const result = await new Promise((resolve) => {
|
||||
child.once('error', (error) => resolve({ code: null, signal: null, error: error.message }));
|
||||
child.once('close', (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
clearInterval(interval);
|
||||
await pendingSample;
|
||||
process.off('SIGINT', onSignal);
|
||||
process.off('SIGTERM', onSignal);
|
||||
if (pending) append('\n');
|
||||
fs.closeSync(logFile);
|
||||
fs.closeSync(rssFile);
|
||||
const durationSeconds = Math.round((Date.now() - started) / 1000);
|
||||
const summary = parseSummary(log);
|
||||
const completed = result.code === 0 && summary?.frames > 0;
|
||||
const status = !completed ? 'FAIL' : server ? 'NEEDS_MANUAL_REVIEW' : 'SMOKE_PASS';
|
||||
const report = {
|
||||
status,
|
||||
mode: server ? 'real_server' : 'fake_server',
|
||||
started_at: new Date(started).toISOString(), duration_seconds: durationSeconds,
|
||||
minimum_seconds: server ? options.minSeconds : null,
|
||||
minimum_met: server ? durationSeconds >= options.minSeconds : null,
|
||||
exit: result, summary,
|
||||
rss: { samples: samples.length, first_kib: samples[0]?.rss_kib ?? null,
|
||||
last_kib: samples.at(-1)?.rss_kib ?? null,
|
||||
max_kib: samples.length ? Math.max(...samples.map((row) => row.rss_kib)) : null },
|
||||
manual_checks: server ? {
|
||||
login: null, character_selection: null, movement: null, combat: null,
|
||||
map_change: null, inventory_and_chat: null, resize_and_focus: null,
|
||||
visual_comparison: null,
|
||||
} : null,
|
||||
note: server ? 'Review manual checks and frame/RSS trends; this runner does not certify gameplay or memory leaks.'
|
||||
: 'Fake-server smoke checks startup and rendering only.',
|
||||
};
|
||||
const reportPath = path.join(options.output, 'report.json');
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
|
||||
console.log(`Native acceptance: ${status}; report=${reportPath}`);
|
||||
return completed ? 0 : 1;
|
||||
}
|
||||
|
||||
main().then((code) => { process.exitCode = code; }).catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user