feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档

- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay
  只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续
  运行并记 BLOCKED;写本批次 release-manifest.json 后聚合
- run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理);
  soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数)
- 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试
- forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别
- 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求
- .gitignore 排除本地场景配置、凭据文件与运行输出

离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS,
node 夹具测试 PASS。未联网运行,未重建候选包。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
This commit is contained in:
shenlei
2026-09-11 22:06:48 +09:00
co-authored by Claude Opus 5
parent 374d4165d8
commit f39a55fdd5
66 changed files with 9189 additions and 951 deletions
+410 -95
View File
@@ -1,130 +1,445 @@
#!/usr/bin/env node
/* Static prerequisite audit for the first playable-map fixture. It does not
* connect to a server and cannot prove which monsters a live server spawned. */
/* Static prerequisite audit for the first playable-map fixture (FIRST-MAC-PLAYABLE §8.1).
* It reads the real map setting, TextureSet, AreaData/Property, npclist, MSM and motlist,
* resolves every referenced file the way the runtime does, and lists missing items.
* It does not connect to a server and cannot prove which monsters a live server spawned:
* tree monsters are reported as candidates only.
*
* exit: 0 = no unexplained gaps, 1 = missing resources, 2 = config/precondition blocked. */
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
const USAGE = `usage: audit_playable_maps.mjs [--config SCENARIO.json | --maps DIR[,DIR...]] [--assets DIR] [--races N[,N...]] [--output FILE]
--config scenario JSON; its map_key is audited (credentials are never read from it)
--maps explicit map directories relative to the asset root
--assets asset root (default: assets)
--races candidate monster races (default: npclist entries 2301-2315)
--output report path (default: build/playable/map-assets.json)`;
const DEFAULT_MAPS = ['outdoortrent/metin2_map_trent', 'outdoortrent02/metin2_map_trent02'];
const PROPERTY_EXTENSIONS = ['.prb', '.prt', '.pre', '.prd', '.pra'];
// Keep in sync with project/ui/mob_view.gd STATE_MOTIONS (first name = primary motion).
const STATE_MOTIONS = {
wait: ['WAIT', 'WAIT1'],
run: ['RUN', 'WALK'],
attack: ['NORMAL_ATTACK', 'NORMAL_ATTACK1', 'SPECIAL_1'],
damage: ['FRONT_DAMAGE', 'DAMAGE', 'BACK_DAMAGE'],
dead: ['FRONT_DEAD', 'DEAD', 'BACK_DEAD'],
};
// Reference CActorInstance::Move -> SetLoopMotion(RUN/WALK): a GetMotionKey miss returns early
// and the current loop keeps playing. Only these states may be explained by that rule.
const REFERENCE_KEEP_CURRENT_STATES = ['run'];
const TILE_FILES = ['areadata.txt', 'height.raw', 'tile.raw', 'attr.atr'];
class Blocked extends Error {}
function parseArgs(argv) {
const out = { assets: 'assets', output: '', maps: ['outdoortrent/metin2_map_trent', 'outdoortrent02/metin2_map_trent02'] };
const out = { assets: 'assets', output: 'build/playable/map-assets.json', maps: '', config: '', races: '' };
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a === '--help') { console.log('usage: audit_playable_maps.mjs [--assets DIR] [--maps DIR[,DIR...]] [--output FILE]'); process.exit(0); }
if (!a.startsWith('--') || i + 1 >= argv.length) throw new Error(`invalid argument: ${a}`);
if (a === '--help' || a === '-h') { console.log(USAGE); process.exit(0); }
if (!a.startsWith('--') || i + 1 >= argv.length) throw new Blocked(`invalid argument: ${a}`);
const k = a.slice(2).replaceAll('-', '_');
if (!(k in out)) throw new Blocked(`unknown option: ${a}`);
out[k] = argv[++i];
}
if (typeof out.maps === 'string') out.maps = out.maps.split(',').map(s => s.trim()).filter(Boolean);
if (out.config && out.maps) throw new Blocked('use either --config or --maps, not both');
return out;
}
function filesUnder(root) {
if (!fs.existsSync(root)) return [];
const result = [];
const visit = dir => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) visit(full); else result.push(full);
}
};
visit(root);
return result;
}
const norm = value => value.replaceAll('\\', '/').toLowerCase().replace(/^.*?ymir work\//, 'ymir work/');
const read = file => fs.readFileSync(file, 'utf8');
const posix = value => value.replaceAll('\\', '/');
const read = file => fs.readFileSync(file, 'latin1');
const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
function parseProperties(allFiles) {
const byId = new Map();
for (const file of allFiles.filter(f => f.toLowerCase().endsWith('.prt'))) {
const lines = read(file).split(/\r?\n/);
const id = lines[1]?.trim();
if (!/^\d+$/.test(id || '')) continue;
const text = lines.join('\n');
const tree = text.match(/^\s*treefile\s+"([^"]+)"/im)?.[1] || '';
const type = text.match(/^\s*propertytype\s+"([^"]+)"/im)?.[1] || '';
byId.set(id, { id: Number(id), file, type, treefile: tree });
// Mirrors fmt::AssetResolver: files are indexed by their asset-root relative path and by the
// suffix after "ymir work/", with the pack priority deciding which real file wins.
class AssetIndex {
constructor(root) {
this.root = root;
this.byRel = new Map();
this.byYmir = new Map();
const priority = ['zone', 'terrain', 'etc', 'pc', 'tree', 'property', 'effect', 'monster', 'npc', 'season2', 'season3_eu'];
const rank = rel => {
const top = rel.split('/')[0].toLowerCase();
const i = priority.indexOf(top);
if (i >= 0) return i;
return top.startsWith('metin2_patch') ? 2000 : 1000;
};
const files = [];
const visit = dir => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) visit(full); else files.push(posix(path.relative(root, full)));
}
};
if (fs.existsSync(root)) visit(root);
files.sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
for (const rel of files) {
const lower = rel.toLowerCase();
if (!this.byRel.has(lower)) this.byRel.set(lower, rel);
const at = lower.indexOf('ymir work/');
if (at >= 0) {
const key = lower.slice(at + 'ymir work/'.length);
if (!this.byYmir.has(key)) this.byYmir.set(key, rel);
}
}
this.files = files;
this.dirs = new Set();
for (const rel of files) {
const parts = rel.toLowerCase().split('/');
for (let i = 1; i < parts.length; i += 1) this.dirs.add(parts.slice(0, i).join('/'));
}
this.topDirs = [...new Set(files.filter(rel => rel.includes('/')).map(rel => rel.split('/')[0]))].sort();
}
return byId;
hasDir(relDir) { return this.dirs.has(AssetIndex.normalize(relDir)); }
static normalize(virtualPath) {
let s = posix(String(virtualPath || '')).toLowerCase();
if (/^[a-z]:/.test(s)) s = s.slice(2);
return s.replace(/^\/+/, '').replace(/\/{2,}/g, '/');
}
// Returns the asset-root relative real path, or ''.
resolve(virtualPath) {
const s = AssetIndex.normalize(virtualPath);
if (!s) return '';
const at = s.indexOf('ymir work/');
if (at >= 0) return this.byYmir.get(s.slice(at + 'ymir work/'.length)) || '';
return this.byRel.get(s) || '';
}
rel(relPath) { return this.byRel.get(AssetIndex.normalize(relPath)) || ''; }
abs(rel) { return path.join(this.root, rel); }
}
function parseArea(file) {
const text = read(file);
function parseKeyValues(text) {
const out = {};
for (const line of text.split(/\r?\n/)) {
const m = line.trim().match(/^([A-Za-z_][\w]*)\s+(.*)$/);
if (m && !(m[1].toLowerCase() in out)) out[m[1].toLowerCase()] = m[2].trim();
}
return out;
}
// Property file: line 1 "YPRT", line 2 CRC, then key "value" pairs (formats/property.h).
function parseProperty(index, rel) {
const lines = read(index.abs(rel)).split(/\r?\n/);
const crc = lines[1]?.trim();
if (!/^\d+$/.test(crc || '')) return null;
const kv = {};
for (const line of lines.slice(2)) {
const m = line.trim().match(/^(\S+)\s+"([^"]*)"/);
if (m) kv[m[1].toLowerCase()] = m[2];
}
return { crc: Number(crc), file: rel, type: kv.propertytype || '', name: kv.propertyname || '', kv };
}
function propertyRegistries(index) {
const runtime = new Map();
const outside = new Map();
let collisions = 0;
for (const rel of index.files) {
if (!PROPERTY_EXTENSIONS.includes(path.extname(rel).toLowerCase())) continue;
const prop = parseProperty(index, rel);
if (!prop) continue;
// PropertyRegistry::scan_list only accepts the top-level Property/ directory.
const target = rel.toLowerCase().startsWith('property/') ? runtime : outside;
if (target.has(prop.crc)) { if (target === runtime) collisions += 1; continue; }
target.set(prop.crc, prop);
}
return { runtime, outside, collisions };
}
function parseObjects(text) {
const objects = [];
const blockRe = /Start Object\d+\r?\n([^]*?)\r?\nEnd Object/g;
for (const match of text.matchAll(blockRe)) {
for (const match of text.matchAll(/Start Object\d+\r?\n([^]*?)\r?\nEnd Object/g)) {
const lines = match[1].split(/\r?\n/).map(s => s.trim()).filter(Boolean);
if (lines.length < 2) continue;
const id = Number(lines[1]);
if (Number.isInteger(id)) objects.push({ property_id: id, position: lines[0] });
if (lines.length < 2 || !/^\d+$/.test(lines[1])) continue;
objects.push({ crc: Number(lines[1]), position: lines[0] });
}
return objects;
}
function parseNpcList(file) {
const result = new Map();
if (!fs.existsSync(file)) return result;
for (const line of read(file).split(/\r?\n/)) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 2 && /^\d+$/.test(parts[0])) result.set(Number(parts[0]), parts[1]);
function parseTextureSet(text) {
const entries = [];
for (const match of text.matchAll(/Start Texture(\d+)\r?\n([^]*?)\r?\nEnd Texture/g)) {
const file = match[2].match(/"([^"]*)"/)?.[1] ?? '';
entries.push({ index: Number(match[1]), file });
}
return result;
return entries;
}
function mobRecord(assets, code, race, allFiles) {
const candidates = allFiles.filter(file => file.toLowerCase().endsWith('.msm') && path.basename(file, '.msm').toLowerCase() === code.toLowerCase())
.filter(file => file.toLowerCase().includes(`/monster2/`) || file.toLowerCase().includes(`/monster/`));
const msm = candidates[0] || '';
const dir = msm ? path.dirname(msm) : '';
const sibling = name => dir && fs.existsSync(path.join(dir, name)) ? path.join(dir, name) : '';
const motlist = sibling('motlist.txt');
const actions = motlist ? [...read(motlist).matchAll(/^\s*\S+\s+([A-Z0-9_]+)\s+([^\s]+\.msa)/gmi)].map(m => ({ name: m[1], file: m[2], exists: fs.existsSync(path.join(dir, m[2])) })) : [];
return {
race, code, directory: dir ? path.relative(assets, dir).replaceAll('\\', '/') : '',
files: { msm: Boolean(msm), gr2: Boolean(sibling(`${code}.gr2`)), dds: Boolean(sibling(`${code}.dds`)), motlist: Boolean(motlist) },
action_count: actions.length, actions,
required_states: Object.fromEntries(['WAIT', 'RUN', 'NORMAL_ATTACK', 'FRONT_DAMAGE', 'FRONT_DEAD'].map(state => [state, actions.some(a => a.name === state)])),
source_sha256: msm ? sha256(msm) : null,
};
function parseNpcList(index) {
const result = new Map();
for (const candidate of ['root/npclist.txt', 'npclist.txt', 'locale/npclist.txt']) {
const rel = index.rel(candidate);
if (!rel) continue;
for (const line of read(index.abs(rel)).split(/\r?\n/)) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 2 && /^\d+$/.test(parts[0])) result.set(Number(parts[0]), parts[1]);
}
return { file: rel, entries: result };
}
return { file: '', entries: result };
}
function auditMap(index, registries, key, source, missing) {
const note = (kind, ref, reason, extra = {}) => missing.push({ scope: `map:${key}`, kind, ref, reason, ...extra });
const rootRel = index.rel(path.posix.join(key, 'setting.txt'));
const map = { key, source, exists: false, setting: '', setting_values: {}, tiles: [], texture_set: null,
environment: null, regen: null, object_count: 0, static_spt: [], static_gr2: [], effects: [], ambience: [],
unresolved_properties: [], property_count: 0 };
if (!rootRel) {
note('map_setting', `${key}/Setting.txt`, 'map directory or Setting.txt not found under the asset root');
return map;
}
map.exists = true;
map.setting = rootRel;
const dirRel = path.posix.dirname(rootRel);
const setting = parseKeyValues(read(index.abs(rootRel)));
const [sizeX, sizeY] = (setting.mapsize || '').split(/\s+/).map(Number);
map.setting_values = { map_size: [sizeX || 0, sizeY || 0], base_position: (setting.baseposition || '').split(/\s+/).map(Number),
cell_scale: Number(setting.cellscale || 0), texture_set: setting.textureset || '', environment: setting.environment || '' };
if (!(sizeX > 0 && sizeY > 0)) note('map_setting', rootRel, 'MapSize is missing or invalid');
// Tiles: the runtime loads <tx*1000+ty padded to 6>/ for every MapSize cell (m2coord::tile_dir).
const objects = [];
const ambienceObjects = [];
for (let tx = 0; tx < (sizeX || 0); tx += 1) {
for (let ty = 0; ty < (sizeY || 0); ty += 1) {
const tile = String(tx * 1000 + ty).padStart(6, '0');
const files = Object.fromEntries(TILE_FILES.map(name => [name, Boolean(index.rel(`${dirRel}/${tile}/${name}`))]));
map.tiles.push({ tile, files });
for (const [name, present] of Object.entries(files)) if (!present) note('tile_file', `${dirRel}/${tile}/${name}`, 'tile file missing');
const area = index.rel(`${dirRel}/${tile}/areadata.txt`);
if (area) objects.push(...parseObjects(read(index.abs(area))).map(o => ({ ...o, tile })));
const ambience = index.rel(`${dirRel}/${tile}/areaambiencedata.txt`);
if (ambience) ambienceObjects.push(...parseObjects(read(index.abs(ambience))).map(o => ({ ...o, tile })));
}
}
map.object_count = objects.length;
map.ambience_object_count = ambienceObjects.length;
// TextureSet: runtime reads <assets>/textureset/<TextureSet lowercased>.
if (setting.textureset) {
const tsRel = index.rel(`textureset/${posix(setting.textureset).toLowerCase()}`);
map.texture_set = { ref: setting.textureset, file: tsRel, textures: [] };
if (!tsRel) note('texture_set', setting.textureset, 'TextureSet file not found under textureset/');
else {
map.texture_set.textures = parseTextureSet(read(index.abs(tsRel))).map(entry => {
const real = entry.file ? index.resolve(entry.file) : '';
if (entry.file && !real) note('terrain_texture', entry.file, 'TextureSet entry does not resolve');
return { ...entry, resolved: real };
});
if (map.texture_set.textures.length === 0) note('texture_set', tsRel, 'TextureSet has no texture entries');
}
} else note('texture_set', rootRel, 'Setting.txt has no TextureSet');
if (setting.environment) {
const real = index.resolve(`d:/ymir work/environment/${setting.environment}`);
map.environment = { ref: setting.environment, resolved: real };
if (!real) note('environment', setting.environment, 'environment .msenv does not resolve');
}
const regenRel = index.rel(`${dirRel}/regen.txt`);
const regenEntries = regenRel ? read(index.abs(regenRel)).split(/\r?\n/).filter(l => l.trim() && !l.trim().startsWith('//')).length : 0;
map.regen = { file: regenRel, entries: regenEntries,
note: regenEntries === 0 ? 'no client-side spawn data; server spawn membership is unknown' : 'client regen.txt is not proof of live server spawns' };
const manifestRel = index.rel('TreeGeometry/manifest.json');
const manifest = manifestRel ? JSON.parse(fs.readFileSync(index.abs(manifestRel), 'utf8')) : { trees: {} };
const nativeBySource = new Map(Object.values(manifest.trees || {}).map(item => [index.resolve(item.source) || AssetIndex.normalize(item.source), item]));
const groups = new Map();
const counts = new Map();
for (const o of [...objects, ...ambienceObjects]) counts.set(o.crc, (counts.get(o.crc) || 0) + 1);
map.property_count = counts.size;
for (const [crc, instances] of counts) {
const prop = registries.runtime.get(crc);
if (!prop) {
const elsewhere = registries.outside.get(crc);
const item = { property_id: crc, instances, found_outside_runtime_scope: elsewhere ? elsewhere.file : '' };
map.unresolved_properties.push(item);
note('property', String(crc), elsewhere ? 'CRC only exists outside Property/, which the runtime registry does not scan'
: 'CRC not found in any property file', { instances });
continue;
}
const base = { property_id: crc, instances, property_file: prop.file, property_type: prop.type, name: prop.name };
const type = prop.type.toLowerCase();
if (type === 'tree') {
const ref = prop.kv.treefile || '';
const spt = ref ? index.resolve(ref) : '';
const native = spt ? nativeBySource.get(spt) : undefined;
const glb = native ? index.rel(`TreeGeometry/${native.glb}`) : '';
const item = { ...base, treefile: ref, spt, native_glb: glb, source_sha256: native?.source_sha256 || null };
map.static_spt.push(item);
if (!spt) note('tree_spt', ref || prop.file, 'treefile does not resolve', { instances });
else if (!glb) note('tree_native_geometry', spt, 'no extracted TreeGeometry entry for this SPT', { instances });
} else if (type === 'building' || type === 'dungeonblock') {
const ref = prop.kv[type === 'building' ? 'buildingfile' : 'dungeonblockfile'] || '';
const gr2 = ref ? index.resolve(ref) : '';
map.static_gr2.push({ ...base, model: ref, gr2, sha256: gr2 ? sha256(index.abs(gr2)) : null });
if (!gr2) note('building_gr2', ref || prop.file, 'model file does not resolve', { instances });
} else if (type === 'effect') {
const ref = prop.kv.effectfile || '';
const mse = ref ? index.resolve(ref) : '';
// Metin2World skips Effect objects for static rendering; still audit the file.
map.effects.push({ ...base, effectfile: ref, mse, rendered_by_world_loader: false });
if (!mse) note('effect_mse', ref || prop.file, 'effect file does not resolve', { instances });
} else if (type === 'ambience') {
const refs = (prop.kv.ambiencesoundvector || '').split(/[,;]/).map(s => s.trim()).filter(Boolean);
const sounds = refs.map(ref => ({ ref, resolved: index.resolve(ref) || index.rel(ref) || index.rel(`sound/${ref}`) }));
map.ambience.push({ ...base, sounds });
for (const s of sounds) if (!s.resolved) note('ambience_sound', s.ref, 'ambience sound does not resolve', { instances });
} else {
map.unresolved_properties.push({ ...base, unsupported_type: true });
note('property_type', prop.file, `unsupported property type "${prop.type}"`, { instances });
}
}
map.static_spt_instances = map.static_spt.reduce((n, item) => n + item.instances, 0);
map.static_gr2_instances = map.static_gr2.reduce((n, item) => n + item.instances, 0);
return map;
}
// Mirrors MobView._find_dir + the npclist stem segment fallback (bear_brown -> bear).
function findMobDir(index, code) {
const segs = code.split('_');
for (let cut = segs.length; cut > 0; cut -= 1) {
const folder = segs.slice(0, cut).join('_');
const rels = ['ymir work/monster', 'ymir work/monster2', 'ymir work/npc', 'ymir work/npc2',
'Monster/ymir work/monster', 'Monster/ymir work/monster2', 'NPC/ymir work/npc', 'NPC/ymir work/npc2'].map(r => `${r}/${folder}`);
for (const rel of rels) if (index.hasDir(rel)) return rel;
for (const sub of index.topDirs) for (const rel of rels) if (index.hasDir(`${sub}/${rel}`)) return `${sub}/${rel}`;
}
return '';
}
function auditMob(index, race, code, missing, explained) {
const scope = `race:${race}`;
const note = (kind, ref, reason) => missing.push({ scope, kind, ref, reason });
const record = { race, code, candidate_only: true, directory: '', files: {}, msm: null, textures: [], motions: [], states: {} };
if (!code) { note('npclist', String(race), 'race is not listed in npclist'); return record; }
const dir = findMobDir(index, code);
record.directory = dir;
if (!dir) { note('mob_directory', code, 'no monster/npc directory for this code'); return record; }
const inDir = name => index.rel(`${dir}/${name}`);
const gr2 = inDir(`${code}.gr2`) || inDir(`${path.posix.basename(dir)}.gr2`);
const msmRel = inDir(`${code}.msm`);
record.files = { gr2, msm: msmRel, motlist: inDir('motlist.txt') };
record.source_sha256 = gr2 ? sha256(index.abs(gr2)) : null;
if (!gr2) note('mob_gr2', `${dir}/${code}.gr2`, 'base model GR2 missing (dynamic skinned model)');
if (msmRel) {
const base = read(index.abs(msmRel)).match(/BaseModelFileName\s+"([^"]+)"/i)?.[1] || '';
const resolved = base ? index.resolve(base) : '';
record.msm = { file: msmRel, base_model: base, resolved, matches_runtime_gr2: Boolean(resolved && gr2 && resolved.toLowerCase() === gr2.toLowerCase()) };
if (base && !resolved) note('msm_base_model', base, 'MSM BaseModelFileName does not resolve');
} else note('mob_msm', `${dir}/${code}.msm`, 'MSM missing');
const dirPrefix = `${dir.toLowerCase()}/`;
record.textures = index.files.filter(f => f.toLowerCase().startsWith(dirPrefix) && !f.slice(dirPrefix.length).includes('/') && f.toLowerCase().endsWith('.dds'));
if (record.textures.length === 0) note('mob_texture', dir, 'no .dds texture next to the model');
const motions = new Map();
if (record.files.motlist) {
for (const line of read(index.abs(record.files.motlist)).split(/\r?\n/)) {
const parts = line.trim().split(/\s+/);
if (parts.length < 3 || !parts[2].toLowerCase().endsWith('.msa')) continue;
const file = inDir(parts[2]);
record.motions.push({ group: parts[0], name: parts[1], file: parts[2], exists: Boolean(file), weight: Number(parts[3] || 0) });
if (!file) note('motion_file', `${dir}/${parts[2]}`, `motlist ${parts[1]} references a missing .msa`);
else if (!motions.has(parts[1])) motions.set(parts[1], file);
}
} else note('motlist', `${dir}/motlist.txt`, 'motlist missing');
for (const [state, names] of Object.entries(STATE_MOTIONS)) {
let entry = null;
for (const name of names) {
if (motions.has(name)) { entry = { motion: name, file: motions.get(name), fallback_reason: name === names[0] ? '' : `client_alias:${name}` }; break; }
}
if (!entry) {
for (const name of names) {
const file = inDir(`${name.toLowerCase()}.msa`);
if (file) { entry = { motion: name, file, fallback_reason: `motlist_missing:${path.posix.basename(file)}` }; break; }
}
}
if (!entry) {
entry = { motion: '', file: '', fallback_reason: 'reference_keep_current_motion' };
if (REFERENCE_KEEP_CURRENT_STATES.includes(state)) {
explained.push({ scope, kind: 'motion', ref: state, reason: `no ${names.join('/')} motion; reference SetLoopMotion keeps the current loop`,
needs_live_confirmation: 'a moving server instance of this race keeps its idle loop while its position changes' });
} else note('motion', `${code}:${state}`, `no ${names.join('/')} motion for a required state`);
}
record.states[state] = entry;
}
return record;
}
function mapsFromConfig(configPath) {
if (!fs.existsSync(configPath)) throw new Blocked(`config not found: ${configPath}`);
let config;
try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (error) { throw new Blocked(`config is not valid JSON: ${error.message}`); }
const mapKey = String(config.map_key || '').trim();
if (!mapKey) throw new Blocked('config map_key is empty; resolve it from the server/map fixture');
if (mapKey.startsWith('/') || mapKey.includes('\\') || mapKey.split('/').includes('..')) throw new Blocked('config map_key must be a relative asset directory');
return { maps: [mapKey], scenario_id: String(config.scenario_id || '') };
}
function main() {
const options = parseArgs(process.argv.slice(2));
const assets = path.resolve(options.assets);
const allFiles = filesUnder(assets);
const manifestPath = path.join(assets, 'TreeGeometry', 'manifest.json');
const manifest = fs.existsSync(manifestPath) ? JSON.parse(read(manifestPath)) : { trees: {} };
const sourceByName = new Map(Object.values(manifest.trees || {}).map(item => [norm(item.source), item]));
const properties = parseProperties(allFiles);
const maps = options.maps.map(key => {
const root = path.join(assets, key);
const areaFiles = filesUnder(root).filter(f => path.basename(f).toLowerCase() === 'areadata.txt');
const objects = areaFiles.flatMap(file => parseArea(file).map(item => ({ ...item, file: path.relative(assets, file).replaceAll('\\', '/') })));
const propertyIds = [...new Set(objects.map(item => item.property_id))];
const resolved = propertyIds.map(id => {
const prop = properties.get(String(id));
if (!prop) return { property_id: id, resolved: false };
const record = sourceByName.get(norm(prop.treefile));
return { property_id: id, resolved: true, property_type: prop.type, property_file: path.relative(assets, prop.file).replaceAll('\\', '/'),
treefile: prop.treefile, native_tree: Boolean(record), source_sha256: record?.source_sha256 || null };
});
const treeObjects = objects.filter(item => resolved.find(r => r.property_id === item.property_id)?.native_tree);
return { key, exists: fs.existsSync(root), setting: fs.existsSync(path.join(root, 'setting.txt')),
area_files: areaFiles.length, object_count: objects.length, property_count: propertyIds.length,
resolved_properties: resolved.length, unresolved_property_ids: resolved.filter(r => !r.resolved).map(r => r.property_id),
tree_object_count: treeObjects.length, native_tree_object_count: treeObjects.length,
properties: resolved };
});
const npc = parseNpcList(path.join(assets, 'root/npclist.txt'));
const treeMobRaces = [...Array(15)].map((_, i) => 2301 + i).filter(race => npc.has(race));
const mobs = treeMobRaces.map(race => mobRecord(assets, npc.get(race), race, allFiles));
const report = { schema_version: 1, suite: 'playable-map-assets', assets_root: assets,
maps, tree_monster_candidates: mobs,
caveat: 'Static map AreaData and local resources only; server spawn membership and visual correctness require live/map-render tests.' };
if (options.output) { fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true }); fs.writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`); }
console.log(JSON.stringify({ maps: maps.map(m => ({ key: m.key, exists: m.exists, objects: m.object_count, native_tree_objects: m.native_tree_object_count, unresolved: m.unresolved_property_ids.length })), tree_monsters: mobs.length }));
if (maps.some(m => !m.exists || !m.setting || m.area_files === 0) || mobs.some(m => !m.files.msm || !m.files.gr2 || !m.files.dds || !m.files.motlist)) process.exitCode = 1;
if (!fs.existsSync(assets)) throw new Blocked(`asset root not found: ${assets}`);
let mapSource = 'default';
let maps = DEFAULT_MAPS;
let scenarioId = '';
if (options.config) {
const fromConfig = mapsFromConfig(options.config);
maps = fromConfig.maps;
scenarioId = fromConfig.scenario_id;
mapSource = 'config';
} else if (options.maps) {
maps = options.maps.split(',').map(s => s.trim()).filter(Boolean);
mapSource = 'argument';
}
const index = new AssetIndex(assets);
const registries = propertyRegistries(index);
const missing = [];
const explained = [];
const mapReports = maps.map(key => auditMap(index, registries, key, mapSource, missing));
const npc = parseNpcList(index);
if (!npc.file) missing.push({ scope: 'global', kind: 'npclist', ref: 'root/npclist.txt', reason: 'npclist not found' });
const races = options.races ? options.races.split(',').map(Number).filter(Number.isInteger)
: [...Array(15)].map((_, i) => 2301 + i).filter(race => npc.entries.has(race));
if (races.length === 0) missing.push({ scope: 'global', kind: 'tree_monster_candidates', ref: '2301-2315', reason: 'no candidate races found' });
const mobs = races.map(race => auditMob(index, race, npc.entries.get(race) || '', missing, explained));
const report = {
schema_version: 2, suite: 'playable-map-assets', generated_at: new Date().toISOString(),
assets_root: assets, map_source: mapSource, scenario_id: scenarioId, npclist: npc.file,
property_registry: { runtime_properties: registries.runtime.size, outside_runtime_scope: registries.outside.size, crc_collisions: registries.collisions },
maps: mapReports, tree_monster_candidates: mobs,
missing, explained,
status: missing.length === 0 ? 'PASS' : 'FAIL',
caveat: 'Static map AreaData and local resources only; tree monsters are candidates, not proof of live server spawns. Visual correctness requires the render tests and manual sign-off.',
};
const output = path.resolve(options.output);
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
console.log(`MAP_ASSETS ${JSON.stringify({ status: report.status, output, maps: mapReports.map(m => ({ key: m.key, exists: m.exists, objects: m.object_count,
spt: m.static_spt_instances || 0, gr2: m.static_gr2_instances || 0, unresolved: m.unresolved_properties.length })),
tree_monsters: mobs.length, missing: missing.length, explained: explained.length })}`);
for (const item of missing) console.error(`MISSING ${item.scope} ${item.kind} ${item.ref}: ${item.reason}`);
process.exitCode = missing.length === 0 ? 0 : 1;
}
main();
try {
main();
} catch (error) {
if (error instanceof Blocked) {
console.error(`MAP_ASSETS BLOCKED: ${error.message}`);
process.exitCode = 2;
} else {
console.error(`MAP_ASSETS ERROR: ${error.stack || error}`);
process.exitCode = 1;
}
}
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
/* MAP-01 audit regression on a throw-away asset tree. Proves that unresolved
* property CRCs, missing model/texture/motion files and an empty config map_key
* are reported (exit 1 / 2) instead of passing, and that a resource-level
* RUN/WALK gap is only "explained" by the reference keep-current-motion rule. */
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const script = path.join(path.dirname(new URL(import.meta.url).pathname), 'audit_playable_maps.mjs');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-map-audit.'));
let failures = 0;
const check = (ok, label) => { if (ok) console.log(`ok - ${label}`); else { failures += 1; console.error(`not ok - ${label}`); } };
const write = (rel, text) => { const file = path.join(root, rel); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text); };
const object = (i, crc) => `Start Object${String(i).padStart(3, '0')}\n 100.0 -100.0 0.0\n ${crc}\n 0.0#0.0#0.0\n 0\nEnd Object\n`;
const prop = (crc, type, key, value) => `YPRT\n${crc}\n${key}\t\t"${value}"\npropertyname\t\t"p${crc}"\npropertytype\t\t"${type}"\n`;
const motlist = names => names.map(([name, file]) => `GENERAL ${name} ${file} 50`).join('\n') + '\n';
function buildFixture({ broken }) {
fs.rmSync(root, { recursive: true, force: true });
write('mapa/setting.txt', 'ScriptType\tMapSetting\nCellScale\t200\nMapSize\t1\t1\nBasePosition\t0\t0\nTextureSet\ttextureset\\mapa.txt\nEnvironment\ta.msenv\n');
write('mapa/000000/height.raw', 'x');
write('mapa/000000/tile.raw', 'x');
write('mapa/000000/attr.atr', 'x');
const objects = [object(0, 11), object(1, 22), object(2, 33)];
if (broken) objects.push(object(3, 44), object(4, 55));
write('mapa/000000/areadata.txt', `AreaDataFile\n\nObjectCount ${objects.length}\n${objects.join('')}`);
write('textureset/textureset/mapa.txt', 'TextureSet\n\nTextureCount 1\n\nStart Texture001\n "d:\\ymir work\\terrainmaps\\a\\field.dds"\n 5.0\nEnd Texture001\n');
write('Terrain/ymir work/terrainmaps/a/field.dds', 'x');
write('ETC/ymir work/environment/a.msenv', 'x');
write('Property/property/t/tree.prt', prop(11, 'Tree', 'treefile', 'd:/ymir work/tree/a.spt'));
write('Tree/ymir work/tree/a.spt', 'spt');
write('TreeGeometry/manifest.json', JSON.stringify({ trees: { h: { source: 'Tree/ymir work/tree/a.spt', glb: 'h.glb', source_sha256: 'h' } } }));
write('TreeGeometry/h.glb', 'glb');
write('Property/property/b/house.prb', prop(22, 'Building', 'buildingfile', 'd:/ymir work/zone/house.gr2'));
if (!broken) write('Zone/ymir work/zone/house.gr2', 'gr2');
write('Property/property/e/fx.pre', prop(33, 'Effect', 'effectfile', 'd:/ymir work/effect/fx.mse'));
write('Effect/ymir work/effect/fx.mse', 'mse');
// 44 only exists outside Property/ (the runtime registry never sees it); 55 exists nowhere.
write('Zone/property/stray.prb', prop(44, 'Building', 'buildingfile', 'd:/ymir work/zone/house.gr2'));
write('root/npclist.txt', '2301\tent_a\n2302\tent_b\n');
const mob = (code, names) => {
const dir = `monster2/ymir work/monster2/${code}`;
write(`${dir}/${code}.gr2`, 'gr2');
write(`${dir}/${code}.dds`, 'dds');
write(`${dir}/${code}.msm`, `BaseModelFileName "d:\\ymir work\\monster2\\${code}\\${code}.gr2"\n`);
write(`${dir}/motlist.txt`, motlist(names));
for (const [, file] of names) write(`${dir}/${file}`, 'msa');
};
mob('ent_a', [['WAIT', '00.msa'], ['NORMAL_ATTACK', '20.msa'], ['FRONT_DAMAGE', '30.msa'], ['FRONT_DEAD', '31.msa']]);
const b = [['WAIT', '00.msa'], ['RUN', '10.msa'], ['NORMAL_ATTACK', '20.msa'], ['FRONT_DAMAGE', '30.msa']];
if (!broken) b.push(['FRONT_DEAD', '31.msa']);
mob('ent_b', b);
}
function run(args) {
const output = path.join(root, 'out', 'map-assets.json');
const result = spawnSync(process.execPath, [script, '--assets', root, '--output', output, ...args], { encoding: 'utf8' });
const report = fs.existsSync(output) ? JSON.parse(fs.readFileSync(output, 'utf8')) : null;
return { code: result.status, report, stderr: result.stderr };
}
try {
buildFixture({ broken: false });
let r = run(['--maps', 'mapa']);
check(r.code === 0 && r.report?.status === 'PASS', `complete fixture passes (exit ${r.code}) ${r.stderr}`);
check(r.report?.explained.some(e => e.scope === 'race:2301' && e.ref === 'run'), 'missing RUN/WALK is explained by the reference rule');
const map = r.report?.maps[0];
check(map?.static_spt[0]?.native_glb === 'TreeGeometry/h.glb' && map?.static_gr2[0]?.gr2 === 'Zone/ymir work/zone/house.gr2', 'tree SPT and building GR2 resolve');
check(map?.texture_set?.textures[0]?.resolved === 'Terrain/ymir work/terrainmaps/a/field.dds', 'TextureSet entries resolve through ymir work');
check(map?.effects[0]?.mse === 'Effect/ymir work/effect/fx.mse', 'effect property resolves');
check(r.report?.tree_monster_candidates.every(m => m.candidate_only && m.msm?.matches_runtime_gr2), 'MSM base model matches runtime GR2 and races stay candidates');
buildFixture({ broken: true });
r = run(['--maps', 'mapa']);
const kinds = new Set((r.report?.missing || []).map(m => `${m.kind}:${m.ref}`));
check(r.code === 1 && r.report?.status === 'FAIL', `broken fixture fails (exit ${r.code})`);
check(kinds.has('building_gr2:d:/ymir work/zone/house.gr2'), 'missing building GR2 is listed');
check(kinds.has('property:44') && r.report?.maps[0].unresolved_properties.some(p => p.property_id === 44 && p.found_outside_runtime_scope === 'Zone/property/stray.prb'), 'CRC outside Property/ is listed with its location');
check(kinds.has('property:55'), 'unknown CRC is listed');
check(kinds.has('motion:ent_b:dead'), 'missing death motion is not explained away');
check(!kinds.has('motion:ent_a:run'), 'RUN/WALK gap is explained, not missing');
fs.rmSync(path.join(root, 'mapa', '000000', 'attr.atr'));
r = run(['--maps', 'mapa']);
check((r.report?.missing || []).some(m => m.kind === 'tile_file'), 'missing tile file is listed');
write('scenario.local.json', JSON.stringify({ scenario_id: 'x', map_key: '' }));
r = run(['--config', path.join(root, 'scenario.local.json')]);
check(r.code === 2 && /map_key is empty/.test(r.stderr), `empty config map_key is BLOCKED (exit ${r.code})`);
write('scenario.local.json', JSON.stringify({ scenario_id: 'x', map_key: '../mapa' }));
r = run(['--config', path.join(root, 'scenario.local.json')]);
check(r.code === 2, 'map_key escaping the asset root is BLOCKED');
write('scenario.local.json', JSON.stringify({ scenario_id: 'x', map_key: 'mapa' }));
r = run(['--config', path.join(root, 'scenario.local.json')]);
check(r.report?.map_source === 'config' && r.report?.maps[0].key === 'mapa', 'config map_key is the audited map');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
console.log(`audit_playable_maps_test: failures=${failures}`);
process.exitCode = failures ? 1 : 0;
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env bash
# MAP-02: run the packaged client in MT_TEST_MODE=forest_render and seal its report.
#
# - Offline: no server, no credentials. MT_ACCOUNT/MT_PASSWORD are removed from the
# child environment; nothing here reads or writes them. Do not add `set -x`.
# - Owns exactly one child PID; TERM, 10 s grace, KILL, then wait. Never pkills.
# - Maps come only from --maps or the config map_key (filled from the
# audit_playable_maps result); no map path is guessed here.
# - The client writes client-report.json/events/screenshots; this script writes
# report.json only after the real process exit and the full-log gate.
# - Metal screenshot sign-off stays a separate manual release item.
#
# exit: 0 PASS, 1 assert/exit-gate FAIL, 2 config/precondition BLOCKED, 124 wall-clock timeout.
set -euo pipefail
set +x
cd "$(dirname "$0")/.."
repo="$PWD"
app_path=""
maps=""
config_path=""
viewpoints=""
races=""
output_dir=""
timeout_seconds=900
required_arch="${MT_PLAYABLE_REQUIRED_ARCH:-arm64}"
kill_grace_seconds=10
suite="forest_render"
usage() {
cat <<'EOF'
用法: script/forest_map_render_test.sh --app APP (--maps KEYS | --config CONFIG) [选项]
选项:
--app APP 已签名的 arm64 包(build/export/mtgodot-poc.app);新测试模式必须重新构建后运行
--maps K1[,K2] map-assets.json 里的 map_key,逗号分隔(优先于 --config 的 map_key
--config FILE playable 配置;只读取 map_key/scenario_id
--viewpoints FILE 机位夹具(test/playable/forest-viewpoints.<name>.local.json);不给则只有自动候选
--races R1[,R2] 覆盖夹具里的怪物 vnum;不给且夹具为空时放 12 个候选树怪
--output DIR 本次运行目录,必须不存在或为空(默认 build/forest/run-<时间>-<pid>
--timeout-seconds N 墙钟超时,默认 900;超时 TERM10 秒后 KILL
--help 显示本帮助
说明:
自动候选机位、未确认机位、缺失传送点都会使对应用例 BLOCKED;
即使自动用例 PASS,Metal 截图仍需人工签核(发布清单单独的 manual 项)。
环境变量:
MT_PLAYABLE_REQUIRED_ARCH 包必须包含的架构,默认 arm64
退出码: 0 PASS / 1 FAIL / 2 BLOCKED(配置或前置条件)/ 124 墙钟超时
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--app) app_path="${2:-}"; shift 2 ;;
--maps) maps="${2:-}"; shift 2 ;;
--config) config_path="${2:-}"; shift 2 ;;
--viewpoints) viewpoints="${2:-}"; shift 2 ;;
--races) races="${2:-}"; shift 2 ;;
--output) output_dir="${2:-}"; shift 2 ;;
--timeout-seconds) timeout_seconds="${2:-}"; shift 2 ;;
--help) usage; exit 0 ;;
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
esac
done
output_ready=0
blocked() {
echo "FOREST RENDER GATE: BLOCKED $*" >&2
if [ "$output_ready" -eq 1 ]; then echo "BLOCKED $*" >>"$output_dir/gate.log"; fi
exit 2
}
abs_file() { echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"; }
[ -n "$app_path" ] || blocked "需要 --app"
[[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || blocked "--timeout-seconds 必须是正整数"
engine="$app_path/Contents/MacOS/mtgodot-poc"
[ -x "$engine" ] || blocked "找不到可执行包:$app_path"
if [ -n "$config_path" ]; then
[ -f "$config_path" ] || blocked "配置不存在:$config_path"
config_path="$(abs_file "$config_path")"
fi
if [ -n "$viewpoints" ]; then
[ -f "$viewpoints" ] || blocked "机位夹具不存在:$viewpoints"
viewpoints="$(abs_file "$viewpoints")"
fi
if [ -n "$races" ] && ! [[ "$races" =~ ^[1-9][0-9]*(,[1-9][0-9]*)*$ ]]; then
blocked "--races 必须是逗号分隔的正整数 vnum"
fi
# Resolve the map list exactly as the client does (MT_FOREST_MAPS, else config map_key).
map_list="$(node -e '
const fs = require("node:fs");
const [maps, config] = process.argv.slice(1);
let keys = maps.split(",").map((s) => s.trim()).filter(Boolean);
if (keys.length === 0 && config) {
try { const key = String(JSON.parse(fs.readFileSync(config, "utf8")).map_key ?? "").trim(); if (key) keys = [key]; } catch {}
}
process.stdout.write(keys.join(","));
' "$maps" "$config_path")"
[ -n "$map_list" ] || blocked "没有地图:给 --maps,或在配置里填 map-assets.json 审计出的 map_key"
if [ -z "$output_dir" ]; then
output_dir="$repo/build/forest/run-$(date +%Y%m%d-%H%M%S)-$$"
fi
if [ -e "$output_dir" ] && [ -n "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
blocked "输出目录已有内容,拒绝复用历史报告:$output_dir"
fi
mkdir -p "$output_dir"
output_dir="$(cd "$output_dir" && pwd)"
output_ready=1
if ! codesign --verify --strict "$app_path" >/dev/null 2>&1; then
blocked "签名校验失败:codesign --verify --strict $app_path"
fi
archs="$(lipo -archs "$engine" 2>/dev/null || true)"
case " $archs " in
*" $required_arch "*) ;;
*) blocked "包架构 [$archs] 不含 $required_arch" ;;
esac
run_id="$(basename "$output_dir")-$(od -An -N4 -tx4 /dev/urandom | tr -d ' ')"
log="$output_dir/client.log"
client_report="$output_dir/client-report.json"
final_report="$output_dir/report.json"
events="$output_dir/events.jsonl"
# Same formula as forest_map_render_test.gd required_case_ids(); the validator
# fails the run if the client declares a different list.
node -e '
const [file, suite, maps] = process.argv.slice(1);
const cases = ["MAP-FOREST-CONFIG"];
for (const key of maps.split(",")) {
const id = key.split("/").pop();
cases.push(`MAP-FOREST-LOAD-${id}`);
for (const kind of ["flat", "slope", "dense", "warp"]) cases.push(`MAP-FOREST-VP-${id}-${kind}`);
cases.push(`MAP-FOREST-MOTION-${id}`);
}
require("node:fs").writeFileSync(file, JSON.stringify({ schema_version: 1, suite, cases }, null, 2) + "\n");
' "$output_dir/required-cases.json" "$suite" "$map_list"
bash script/playable_build_info.sh "$app_path" >"$output_dir/build.json"
build_field() { node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"))[process.argv[2]]))' "$output_dir/build.json" "$1"; }
started_ms="$(($(date +%s) * 1000))"
set +e
# No credentials reach this child, so its log needs no redactor.
env -u MT_ASSETS -u MT_ACCOUNT -u MT_PASSWORD \
MT_TEST_MODE=forest_render \
MT_FOREST_MAPS="$map_list" \
MT_FOREST_VIEWPOINTS="$viewpoints" \
MT_FOREST_RACES="$races" \
MT_FOREST_TIMEOUT_SECONDS="$timeout_seconds" \
MT_PLAYABLE_CONFIG="$config_path" \
MT_PLAYABLE_RUN_ID="$run_id" \
MT_TEST_REPORT="$client_report" \
MT_TEST_EVENTS="$events" \
MT_TEST_OUTPUT="$output_dir" \
MT_BUILD_ENGINE_SHA256="$(build_field engine_sha256)" \
MT_BUILD_EXTENSION_SHA256="$(build_field extension_sha256)" \
MT_BUILD_PCK_SHA256="$(build_field pck_sha256)" \
MT_BUILD_ARCH="$(build_field arch)" \
"$engine" >"$log" 2>&1 </dev/null &
child_pid=$!
SECONDS=0
timed_out=0
while kill -0 "$child_pid" 2>/dev/null; do
if [ "$SECONDS" -ge "$timeout_seconds" ]; then
timed_out=1
kill -TERM "$child_pid" 2>/dev/null
grace=0
while kill -0 "$child_pid" 2>/dev/null && [ "$grace" -lt "$kill_grace_seconds" ]; do
sleep 1
grace=$((grace + 1))
done
killed=0
if kill -0 "$child_pid" 2>/dev/null; then kill -KILL "$child_pid" 2>/dev/null; killed=1; fi
echo "TIMEOUT term_grace_s=$grace killed=$killed" >>"$output_dir/gate.log"
break
fi
sleep 1
done
wait "$child_pid"
process_code=$?
node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \
--log "$log" --process-code "$process_code" --timed-out "$timed_out" --run-id "$run_id" \
--suite "$suite" --required-cases "$output_dir/required-cases.json" --build "$output_dir/build.json" \
--events "$events" --started-ms "$started_ms" --require-pass
gate_status=$?
set -e
echo "FOREST RENDER GATE: Metal 截图人工签核仍是单独的 manual 项($output_dir/forest-*.png + forest-map-evidence.json"
if [ "$timed_out" -eq 1 ]; then
echo "FOREST RENDER GATE: TIMEOUT raw_exit=$process_code report=$final_report log=$log" >&2
exit 124
fi
case "$gate_status" in
0) echo "FOREST RENDER GATE: PASS run_id=$run_id report=$final_report" ;;
2) echo "FOREST RENDER GATE: BLOCKED raw_exit=$process_code report=$final_report" >&2 ;;
*) echo "FOREST RENDER GATE: FAIL raw_exit=$process_code report=$final_report log=$log" >&2; gate_status=1 ;;
esac
exit "$gate_status"
+27 -5
View File
@@ -8,7 +8,10 @@
# ./script/live_smoke_test.sh --no-reconnect
#
# 账号和密码优先从环境变量读取;未提供时安全地交互输入,不写入脚本。
# 服务器地址与客户端一样经 project/net/serverinfo.gd 解析,不在脚本里写死。
# 不要在本脚本中启用 set -x。
set -euo pipefail
set +x
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -37,6 +40,7 @@ usage() {
环境变量:
MT_ACCOUNT / MT_PASSWORD 可预先提供账号密码,未提供时交互输入
MT_CHAR_SLOT=N 指定角色槽位;不设置时自动选择第一个角色
MT_GODOT 解析 serverinfo 用的 godot,默认 godot
EOF
}
@@ -61,7 +65,8 @@ if [ -f "$CONFIG_FILE" ]; then
fi
if [ -z "${MT_ACCOUNT:-}" ]; then
read -r -p "账号: " MT_ACCOUNT
read -r -s -p "账号: " MT_ACCOUNT
echo
fi
if [ -z "${MT_PASSWORD:-}" ]; then
read -r -s -p "密码: " MT_PASSWORD
@@ -85,9 +90,19 @@ if [ ! -x "$EXECUTABLE" ]; then
fi
echo "== check server ports =="
for port in 11000 13002; do
if ! nc -G 3 -z 192.168.21.203 "$port" >/dev/null 2>&1; then
echo "服务器端口不可达:192.168.21.203:$port" >&2
# 登录页默认 server 0 / 第一个频道;与 AppFlow 同一 ServerInfo 解析。
ADDRESS_LINE="$("${MT_GODOT:-godot}" --headless --path project --script print_server_address.gd 2>/dev/null | grep '^SERVER_ADDRESS ' || true)"
if [ -z "$ADDRESS_LINE" ]; then
echo "无法从 serverinfo 解析服务器地址" >&2
exit 2
fi
read -r AUTH_HOST AUTH_PORT GAME_HOST GAME_PORT <<<"$(node -e '
const a = JSON.parse(process.argv[1].slice("SERVER_ADDRESS ".length));
process.stdout.write([a.auth_host, a.auth_port, a.game_host, a.game_port].join(" "));
' "$ADDRESS_LINE")"
for endpoint in "$AUTH_HOST:$AUTH_PORT" "$GAME_HOST:$GAME_PORT"; do
if ! nc -G 3 -z "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then
echo "服务器端口不可达:$endpoint" >&2
exit 3
fi
done
@@ -111,7 +126,8 @@ echo "== run live smoke =="
echo "log: $LOG"
echo "report: $REPORT"
set +e
"$EXECUTABLE" 2>&1 | tee "$LOG"
# 日志落盘前脱敏;PIPESTATUS[0] 仍是 APP 的真实退出码。
env -u MT_ASSETS "$EXECUTABLE" 2>&1 | node script/redact_stream.mjs | tee "$LOG"
APP_STATUS=${PIPESTATUS[0]}
set -e
@@ -140,6 +156,12 @@ if [ "$APP_STATUS" -ne 0 ]; then
exit "$APP_STATUS"
fi
# 与 package_render_test.sh / validate_playable_report.mjs 相同的完整日志门禁。
if grep -nE 'SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed' "$LOG"; then
echo "LIVE_SMOKE RESULT: FAIL (log exit gate)" >&2
exit 1
fi
if [ "$REPORT_STATUS" != "PASS" ]; then
echo "LIVE_SMOKE RESULT: FAIL (report status=$REPORT_STATUS)" >&2
exit 1
+2 -2
View File
@@ -10,8 +10,8 @@ mkdir -p "$output_dir"
status=0
env -u MT_ASSETS MT_TEST_MODE=render MT_RENDER_OUTPUT="$output_dir" \
"$app_path/Contents/MacOS/mtgodot-poc" --quit-after 1800 >"$output_dir/client.log" 2>&1 || status=$?
if [ "$status" -ne 0 ] || ! rg -q '^PKGRENDER: PASS$' "$output_dir/client.log" || \
rg -q 'SCRIPT ERROR|^ERROR:|leaked at exit|shaders of type .* were never freed' "$output_dir/client.log"; then
if [ "$status" -ne 0 ] || ! grep -qE '^PKGRENDER: PASS$' "$output_dir/client.log" || \
grep -qE 'SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed' "$output_dir/client.log"; then
echo "FAIL: package load/exit gate (exit=$status): $output_dir/client.log"
exit 1
fi
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Print the candidate package identity as JSON (engine/extension/pck sha256 +
# architectures). Shared by run_client_gate.sh and playable_test.sh so the
# per-run build.json and the release manifest are computed the same way.
set -euo pipefail
if [ "${1:-}" = "--help" ] || [ "$#" -ne 1 ]; then
echo "用法: script/playable_build_info.sh APP" >&2
[ "${1:-}" = "--help" ] && exit 0
exit 2
fi
app="$1"
engine="$app/Contents/MacOS/mtgodot-poc"
pck="$app/Contents/Resources/mtgodot-poc.pck"
dylib="$(find "$app/Contents/Frameworks" -maxdepth 1 -type f -name '*.dylib' 2>/dev/null | LC_ALL=C sort | head -n 1)"
sha256_of() {
if [ -n "$1" ] && [ -f "$1" ]; then shasum -a 256 "$1" | awk '{print $1}'; else printf ''; fi
}
archs=""
if [ -f "$engine" ]; then archs="$(lipo -archs "$engine" 2>/dev/null | tr ' ' '+' || true)"; fi
node -e '
const [engine, extension, pck, arch] = process.argv.slice(1);
process.stdout.write(JSON.stringify({ engine_sha256: engine, extension_sha256: extension, pck_sha256: pck, arch }) + "\n");
' "$(sha256_of "$engine")" "$(sha256_of "$dylib")" "$(sha256_of "$pck")" "$archs"
-14
View File
@@ -1,14 +0,0 @@
extends SceneTree
const Config = preload("res://testing/playable_config.gd")
func _init() -> void:
var path := OS.get_environment("MT_PLAYABLE_VALIDATE_CONFIG")
var result := Config.load_file(path)
if not result.ok:
for error in result.errors:
printerr("CONFIG: " + String(error))
quit(2)
return
print("PLAYABLE CONFIG: PASS " + path)
quit(0)
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env node
/* STB-DISCONNECT-01 local transport-fault proxy for ONE test client connection.
*
* playable_fault_proxy.mjs --route LISTEN_PORT=UPSTREAM_HOST:UPSTREAM_PORT [--route ...]
* --events events.jsonl --log fault-proxy.jsonl [--run-id RUN_ID] [--poll-ms 200]
*
* - Listens on 127.0.0.1 only and forwards bytes to the upstream test server. The
* scenario's server.* points the client at these loopback ports; nothing else on
* the machine is touched: no routes, firewall rules, other clients or production.
* - Tails the client's events.jsonl and acts on each `fault_request` exactly once:
* close destroy the proxied connections ("connection closed")
* unreachable destroy them AND stop listening for payload.unreachable_seconds
* (capped at 20 s) so reconnect attempts are refused, then listen again
* - Every action is appended to --log as JSON (wall_ms, action, type, attempt, ...).
* The client, not this proxy, decides whether recovery happened.
* - Exits 0 on SIGTERM/SIGINT after closing its sockets. It never signals any process.
*
* Known limit: a server-side warp that hands out a direct game address bypasses the proxy.
* exit: 0 stopped, 2 usage error or a listen port could not be opened. */
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const LISTEN_HOST = '127.0.0.1';
const MAX_UNREACHABLE_S = 20;
function usage(stream = process.stderr) {
stream.write('usage: playable_fault_proxy.mjs --route LISTEN_PORT=HOST:PORT [--route ...] --events events.jsonl --log fault-proxy.jsonl [--run-id ID] [--poll-ms 200]\n'
+ ' listens on 127.0.0.1 only; acts on fault_request events (close | unreachable)\n');
}
const isPort = (value) => Number.isInteger(value) && value >= 1 && value <= 65535;
export function parseArgs(argv) {
const options = { routes: [], poll_ms: 200 };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--help') { options.help = true; continue; }
const value = argv[i + 1];
if (value === undefined) throw new Error(`missing value for ${arg}`);
i += 1;
if (arg === '--route') {
const match = /^(\d+)=([A-Za-z0-9.:-]+):(\d+)$/.exec(value);
if (!match) throw new Error(`bad --route ${value}`);
const route = { listen: Number(match[1]), host: match[2], port: Number(match[3]) };
if (!isPort(route.listen) || !isPort(route.port)) throw new Error(`bad port in --route ${value}`);
if (['127.0.0.1', 'localhost', '::1'].includes(route.host) && route.port === route.listen) throw new Error(`route ${value} loops back to itself`);
if (options.routes.some((r) => r.listen === route.listen)) throw new Error(`duplicate listen port ${route.listen}`);
options.routes.push(route);
} else if (arg === '--events') options.events = value;
else if (arg === '--log') options.log = value;
else if (arg === '--run-id') options.run_id = value;
else if (arg === '--poll-ms') options.poll_ms = Number(value);
else throw new Error(`unknown option ${arg}`);
}
if (!options.help && (options.routes.length === 0 || !options.events || !options.log
|| !Number.isInteger(options.poll_ms) || options.poll_ms < 20)) throw new Error('--route, --events and --log are required');
return options;
}
/** Incremental JSONL reader: returns complete new lines since the last call. */
export function tailer(file) {
let offset = 0;
let partial = '';
return () => {
let size;
try { size = fs.statSync(file).size; } catch { return []; }
if (size < offset) { offset = 0; partial = ''; }
if (size === offset) return [];
const fd = fs.openSync(file, 'r');
const buffer = Buffer.alloc(size - offset);
fs.readSync(fd, buffer, 0, buffer.length, offset);
fs.closeSync(fd);
offset = size;
const text = partial + buffer.toString('utf8');
const lines = text.split('\n');
partial = lines.pop();
return lines.filter((line) => line.trim() !== '');
};
}
export class FaultProxy {
constructor(options) {
this.options = options;
this.servers = new Map();
this.pairs = new Set();
this.seen = new Set();
this.unreachableTimer = null;
this.stopped = false;
this.read = tailer(options.events);
}
log(action, detail = {}) {
fs.appendFileSync(this.options.log, `${JSON.stringify({ wall_ms: Date.now(), action, ...detail })}\n`);
}
listen(route) {
return new Promise((resolve, reject) => {
const server = net.createServer((client) => this.accept(route, client));
server.once('error', reject);
server.listen(route.listen, LISTEN_HOST, () => {
server.off('error', reject);
this.servers.set(route.listen, server);
resolve();
});
});
}
accept(route, client) {
const upstream = net.connect(route.port, route.host);
const pair = { client, upstream };
this.pairs.add(pair);
const drop = () => {
if (!this.pairs.delete(pair)) return;
client.destroy();
upstream.destroy();
};
client.on('error', drop).on('close', drop);
upstream.on('error', drop).on('close', drop);
client.pipe(upstream);
upstream.pipe(client);
this.log('accepted', { listen_port: route.listen, open: this.pairs.size });
}
dropAll() {
const count = this.pairs.size;
for (const pair of [...this.pairs]) {
this.pairs.delete(pair);
pair.client.destroy();
pair.upstream.destroy();
}
return count;
}
/** Stops accepting, drops open connections (server.close waits for them), returns the drop count. */
async closeListeners() {
const closing = [...this.servers.values()].map((server) => new Promise((resolve) => server.close(resolve)));
this.servers.clear();
const dropped = this.dropAll();
await Promise.all(closing);
return dropped;
}
async start() {
for (const route of this.options.routes) await this.listen(route);
this.log('listening', { listen_ports: this.options.routes.map((r) => r.listen) });
this.poller = setInterval(() => { this.poll().catch((error) => this.log('error', { reason: String(error.message).slice(0, 120) })); }, this.options.poll_ms);
}
async poll() {
// setInterval does not wait for an async fault; never run two polls at once.
if (this.polling) return;
this.polling = true;
try { await this.drain(); } finally { this.polling = false; }
}
async drain() {
for (const line of this.read()) {
let event;
try { event = JSON.parse(line); } catch { continue; }
if (event?.kind !== 'fault_request') continue;
if (this.options.run_id && event.run_id !== this.options.run_id) { this.log('ignored_foreign_run', {}); continue; }
const payload = event.payload ?? {};
const key = `${event.monotonic_us}:${payload.type}:${payload.attempt}`;
if (this.seen.has(key)) continue;
this.seen.add(key);
await this.fault(payload);
}
}
async fault(payload) {
const detail = { type: payload.type, attempt: payload.attempt, round: payload.round };
if (payload.type === 'close') {
this.log('close', { ...detail, closed: this.dropAll() });
} else if (payload.type === 'unreachable') {
if (this.unreachableTimer) { this.log('rejected', { ...detail, reason: 'unreachable window already active' }); return; }
const seconds = Math.min(MAX_UNREACHABLE_S, Math.max(1, Number.parseInt(payload.unreachable_seconds, 10) || 1));
const closed = await this.closeListeners();
this.log('unreachable_start', { ...detail, closed, seconds });
this.unreachableTimer = setTimeout(async () => {
this.unreachableTimer = null;
if (this.stopped) return;
try {
for (const route of this.options.routes) await this.listen(route);
this.log('unreachable_end', detail);
} catch (error) {
this.log('error', { ...detail, reason: `relisten failed: ${error.code || error.message}` });
}
}, seconds * 1000);
} else {
this.log('rejected', { ...detail, reason: 'unknown fault type' });
}
}
async stop() {
if (this.stopped) return;
this.stopped = true;
clearInterval(this.poller);
if (this.unreachableTimer) clearTimeout(this.unreachableTimer);
this.dropAll();
await this.closeListeners();
this.log('stopped', {});
}
}
async function main() {
let options;
try { options = parseArgs(process.argv.slice(2)); } catch (error) { usage(); console.error(error.message); return 2; }
if (options.help) { usage(process.stdout); return 0; }
const proxy = new FaultProxy(options);
try { await proxy.start(); } catch (error) {
console.error(`FAULT PROXY: cannot listen (${error.code || error.message})`);
await proxy.stop();
return 2;
}
await new Promise((resolve) => {
process.once('SIGTERM', resolve);
process.once('SIGINT', resolve);
});
await proxy.stop();
return 0;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = await main();
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env node
/* STB-DISCONNECT-01 fault proxy regression. Uses only 127.0.0.1 ephemeral ports and a
* local echo server as the "upstream"; never contacts the test server. Proves that the
* proxy forwards bytes, closes the connection on `close`, refuses connections for the
* `unreachable` window and listens again, acts on each request once, ignores foreign
* run_ids, and stops cleanly on SIGTERM. */
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { parseArgs } from './playable_fault_proxy.mjs';
const script = path.join(path.dirname(new URL(import.meta.url).pathname), 'playable_fault_proxy.mjs');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-fault-proxy.'));
let failures = 0;
const check = (ok, label) => { if (ok) console.log(`ok - ${label}`); else { failures += 1; console.error(`not ok - ${label}`); } };
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function until(predicate, timeoutMs, stepMs = 50) {
const end = Date.now() + timeoutMs;
while (Date.now() < end) {
if (await predicate()) return true;
await delay(stepMs);
}
return false;
}
function freePort() {
return new Promise((resolve) => {
const server = net.createServer();
server.listen(0, '127.0.0.1', () => { const { port } = server.address(); server.close(() => resolve(port)); });
});
}
function connect(port) {
return new Promise((resolve) => {
const socket = net.connect(port, '127.0.0.1');
const state = { socket, closed: false, data: '', error: null };
socket.on('data', (chunk) => { state.data += chunk.toString(); });
socket.on('close', () => { state.closed = true; });
socket.once('connect', () => resolve(state));
socket.once('error', (error) => { state.error = error.code; resolve(state); });
});
}
const logRows = (file) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []);
const actions = (file, action) => logRows(file).filter((row) => row.action === action);
let usClock = 1000;
const request = (file, type, attempt, extra = {}) => fs.appendFileSync(file, `${JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'run-a',
case_id: 'STB-DISCONNECT-01', connection_epoch: 2, stage: 'FAULT', kind: 'fault_request', actor_vid: 0, target_vid: 0,
payload: { type, attempt, round: 0, timeout_ms: 30000, unreachable_seconds: type === 'unreachable' ? 2 : 0, ...extra } })}\n`);
// Argument contract.
{
const help = spawnSync(process.execPath, [script, '--help'], { encoding: 'utf8' });
check(help.status === 0 && /127\.0\.0\.1/.test(help.stdout), '--help documents loopback-only listening');
for (const [label, args] of [
['no routes', ['--events', 'e', '--log', 'l']],
['malformed route', ['--route', 'abc', '--events', 'e', '--log', 'l']],
['route to itself', ['--route', '4000=127.0.0.1:4000', '--events', 'e', '--log', 'l']],
['port out of range', ['--route', '70000=10.0.0.1:1', '--events', 'e', '--log', 'l']],
['unknown option', ['--route', '4000=10.0.0.1:1', '--events', 'e', '--log', 'l', '--listen-host', '0.0.0.0']],
]) {
const run = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' });
check(run.status === 2, `usage error exits 2: ${label}`);
}
check(parseArgs(['--route', '4000=192.0.2.1:11000', '--events', 'e', '--log', 'l']).routes[0].host === '192.0.2.1', 'route parses upstream host');
}
// Live behaviour against a local echo upstream.
const upstream = net.createServer((socket) => socket.pipe(socket));
await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve));
const upstreamPort = upstream.address().port;
const listenPort = await freePort();
const events = path.join(root, 'events.jsonl');
const log = path.join(root, 'fault-proxy.jsonl');
const proxy = spawn(process.execPath, [script, '--route', `${listenPort}=127.0.0.1:${upstreamPort}`, '--events', events,
'--log', log, '--run-id', 'run-a', '--poll-ms', '50'], { stdio: ['ignore', 'ignore', 'pipe'] });
let exited = null;
proxy.on('exit', (code) => { exited = code; });
check(await until(() => actions(log, 'listening').length === 1, 5000), 'proxy logs listening before any fault');
let a = await connect(listenPort);
a.socket.write('ping');
check(await until(() => a.data === 'ping', 2000), 'bytes are forwarded to the upstream and back');
request(events, 'close', 1);
check(await until(() => a.closed, 2000), 'close request drops the proxied connection');
check(actions(log, 'close')[0]?.closed === 1, 'close action records the dropped connection count');
let b = await connect(listenPort);
b.socket.write('again');
check(!b.error && await until(() => b.data === 'again', 2000), 'the listener stays open after close');
// The same line again (same monotonic_us/type/attempt) is not a second fault.
const lines = fs.readFileSync(events, 'utf8').trim().split('\n');
fs.appendFileSync(events, `${lines.at(-1)}\n`);
await delay(300);
check(!b.closed && actions(log, 'close').length === 1, 'a repeated request line is acted on once');
fs.appendFileSync(events, `${JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'other-run', kind: 'fault_request', payload: { type: 'close', attempt: 9 } })}\n`);
await delay(300);
check(!b.closed && actions(log, 'ignored_foreign_run').length === 1, 'fault requests from another run are ignored');
// Partial line: only acted on once the newline arrives.
const partial = JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'run-a', kind: 'fault_request',
payload: { type: 'unreachable', attempt: 1, unreachable_seconds: 2 } });
fs.appendFileSync(events, partial.slice(0, 20));
await delay(300);
check(!b.closed, 'a half-written event line is not parsed early');
const started = Date.now();
fs.appendFileSync(events, `${partial.slice(20)}\n`);
check(await until(() => b.closed, 2000), 'unreachable request drops the connection');
await until(() => actions(log, 'unreachable_start').length === 1, 1000);
const refused = await connect(listenPort);
check(refused.error === 'ECONNREFUSED', `connections are refused during the unreachable window (${refused.error})`);
check(await until(() => actions(log, 'unreachable_end').length === 1, 5000), 'listener comes back after unreachable_seconds');
const elapsed = Date.now() - started;
check(elapsed >= 1900 && elapsed < 4500, `unreachable window lasts about 2 s (${elapsed} ms)`);
const c = await connect(listenPort);
c.socket.write('back');
check(!c.error && await until(() => c.data === 'back', 2000), 'forwarding works again after the window');
c.socket.destroy();
proxy.kill('SIGTERM');
check(await until(() => exited !== null, 5000) && exited === 0, 'SIGTERM stops the proxy with exit 0');
check(actions(log, 'stopped').length === 1, 'stop is logged');
const after = await connect(listenPort);
check(after.error === 'ECONNREFUSED', 'no listener remains after stop');
check(!fs.readFileSync(log, 'utf8').includes('ping'), 'the action log never contains payload bytes');
// A port already in use is a startup error, not a silent run.
{
const busy = net.createServer();
await new Promise((resolve) => busy.listen(0, '127.0.0.1', resolve));
const run = spawnSync(process.execPath, [script, '--route', `${busy.address().port}=127.0.0.1:${upstreamPort}`, '--events', events,
'--log', path.join(root, 'busy.jsonl')], { encoding: 'utf8', timeout: 10000 });
check(run.status === 2 && /cannot listen/.test(run.stderr), 'busy listen port exits 2');
busy.close();
}
upstream.close();
fs.rmSync(root, { recursive: true, force: true });
console.log(failures === 0 ? 'PASS: playable_fault_proxy_test' : `FAIL: playable_fault_proxy_test (${failures})`);
process.exitCode = failures === 0 ? 0 : 1;
+394 -29
View File
@@ -1,44 +1,409 @@
#!/usr/bin/env bash
# INF-02 negative/positive checks. Uses only static fixtures and a temporary
# output directory; it never starts the game or contacts the server.
# INF-02 runner/exit-gate regression. Builds a throw-away ad-hoc signed arm64
# app whose executable execs test/playable/fake_client.sh, listens on a local
# 127.0.0.1 port, and drives script/run_client_gate.sh through PASS and every
# failure path. Never starts the real client and never contacts the test server.
set -euo pipefail
set +x
cd "$(dirname "$0")/.."
repo="$PWD"
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/mt-playable-gate.XXXXXX")"
trap 'rm -R "$tmp_dir"' EXIT
listener_pid=""
cleanup() {
if [ -n "$listener_pid" ]; then kill "$listener_pid" 2>/dev/null || true; fi
if [ -z "${MT_GATE_TEST_KEEP:-}" ]; then rm -R "$tmp_dir"; else echo "kept $tmp_dir"; fi
}
trap cleanup EXIT
node script/validate_playable_report.mjs \
--report test/playable/report.valid.json \
--output "$tmp_dir/valid.json" \
--log test/playable/clean.log --process-code 0 --require-pass
failures=0
pass() { echo "ok - $*"; }
fail() { echo "not ok - $*" >&2; failures=$((failures + 1)); }
expect_code() { # expected actual label
if [ "$1" -eq "$2" ]; then pass "$3 (exit $2)"; else fail "$3: expected exit $1, got $2"; fi
}
if node script/validate_playable_report.mjs \
--report test/playable/report.blocked.json \
--output "$tmp_dir/blocked.json" \
--log test/playable/clean.log --process-code 0 --require-pass; then
echo "FAIL: BLOCKED fixture was accepted" >&2
exit 1
# ---------- validator unit checks on static fixtures ----------
fixture="$repo/test/playable"
validate_fixture() { # report log process_code timed_out [extra...]
local report="$1" log="$2" code="$3" timed="$4"
shift 4
node script/validate_playable_report.mjs --report "$report" --output "$tmp_dir/sealed.json" --log "$log" \
--process-code "$code" --timed-out "$timed" --run-id fixture-run --suite playable \
--required-cases "$tmp_dir/v/required-cases.json" --build "$tmp_dir/v/build.json" \
--events "$tmp_dir/v/events.jsonl" --started-ms "$(($(date +%s) * 1000))" --require-pass "$@" >/dev/null 2>&1
}
reset_fixture() {
rm -rf "$tmp_dir/v"
mkdir -p "$tmp_dir/v"
cp "$fixture/report.valid.json" "$tmp_dir/v/client-report.json"
cp "$fixture/required-cases.fixture.json" "$tmp_dir/v/required-cases.json"
cp "$fixture/build.fixture.json" "$tmp_dir/v/build.json"
cp "$fixture/events.valid.jsonl" "$tmp_dir/v/events.jsonl"
}
mutate() { # js expression body operating on `r`
node -e 'const fs=require("fs");const f=process.argv[1];const r=JSON.parse(fs.readFileSync(f,"utf8"));'"$1"';fs.writeFileSync(f,JSON.stringify(r));' "$tmp_dir/v/client-report.json"
}
code_of() { set +e; "$@"; local c=$?; set -e; echo "$c"; }
quiet() { "$@" >/dev/null 2>&1; }
reset_fixture
expect_code 0 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: valid fixture"
sealed_checked="$(node -e 'const r=require(process.argv[1]);process.stdout.write(String(r.exit_gate.checked===true&&r.exit_gate.process_code===0))' "$tmp_dir/sealed.json")"
[ "$sealed_checked" = "true" ] && pass "validator: exit_gate sealed" || fail "validator: exit_gate not sealed"
cp "$fixture/report.blocked.json" "$tmp_dir/v/client-report.json"
expect_code 2 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: BLOCKED case is not releasable"
reset_fixture
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 1 0)" "validator: nonzero child exit"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 1)" "validator: timed out"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/rid-warning.log" 0 0)" "validator: RID warning in log"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$tmp_dir/missing.log" 0 0)" "validator: missing log"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/missing.json" "$fixture/clean.log" 0 0)" "validator: missing client report"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0 --redactor-code 143)" "validator: redactor killed"
mutate 'r.schema_version=2'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: unknown schema"
reset_fixture; mutate 'r.suite="full"'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: suite mismatch"
reset_fixture; mutate 'r.cases=[];r.required_cases=[];r.coverage={required:0,passed:0}'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: empty cases"
reset_fixture; mutate 'r.cases.push(r.cases[0]);r.coverage.required=2;r.coverage.passed=2'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: duplicate case"
reset_fixture; mutate 'r.build.pck_sha256=""'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: missing hash"
reset_fixture; mutate 'r.build.engine_sha256="d".repeat(64)'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: build differs from runner build.json"
reset_fixture; mutate 'r.cases[0].evidence=["shot.png"]'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: missing evidence file"
reset_fixture; mutate 'r.cases[0].evidence=["../../etc/hosts"]'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: evidence path escape"
reset_fixture; mutate 'r.event_count=5'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: event_count mismatch"
reset_fixture; sed 's/fixture-run/other-run/' "$fixture/events.valid.jsonl" >"$tmp_dir/v/events.jsonl"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: foreign run_id in events"
reset_fixture; node -e 'require("fs").writeFileSync(process.argv[1], JSON.stringify({schema_version:1,suite:"playable",cases:["FIXTURE-01","FIXTURE-02"]}))' "$tmp_dir/v/required-cases.json"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: required count differs from case list"
reset_fixture; touch -t 200001010000 "$tmp_dir/v/client-report.json"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: stale report mtime"
reset_fixture; mutate 'r.cases[0].reason="fixture-secret-value"'
code="$(MT_PASSWORD=fixture-secret-value code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)"
expect_code 1 "$code" "validator: credential literal in client report"
if grep -q "fixture-secret-value" "$tmp_dir/sealed.json"; then fail "validator: secret copied into sealed report"; else pass "validator: secret not persisted"; fi
# ---------- log redactor ----------
redacted="$(printf 'user fixture-acct pass fixture-acct-pw\n' | MT_ACCOUNT=fixture-acct MT_PASSWORD=fixture-acct-pw node script/redact_stream.mjs)"
[ "$redacted" = "user [redacted] pass [redacted]" ] && pass "redactor: longest literal first" || fail "redactor output: $redacted"
# ---------- scripts never trace ----------
if grep -nE '^[[:space:]]*set[[:space:]]+-[a-zA-Z]*x' script/run_client_gate.sh script/playable_test.sh script/playable_soak.sh script/live_smoke_test.sh script/forest_map_render_test.sh 2>/dev/null; then
fail "a launch script enables set -x"
else
pass "launch scripts never enable set -x"
fi
if node script/validate_playable_report.mjs \
--report test/playable/report.valid.json \
--output "$tmp_dir/exit.json" \
--log test/playable/clean.log --process-code 1 --require-pass; then
echo "FAIL: non-zero child exit was accepted" >&2
exit 1
# ---------- example config is rejected ----------
if MT_PLAYABLE_VALIDATE_CONFIG="$repo/test/playable/scenario.example.json" MT_PLAYABLE_ASSETS="$tmp_dir" \
"${MT_GODOT:-godot}" --headless --path project --script playable_config_gate.gd >/dev/null 2>&1; then
fail "empty example scenario accepted"
else
pass "empty example scenario rejected"
fi
if node script/validate_playable_report.mjs \
--report test/playable/report.valid.json \
--output "$tmp_dir/rid.json" \
--log test/playable/rid-warning.log --process-code 0 --require-pass; then
echo "FAIL: renderer warning was accepted" >&2
exit 1
# ---------- fake signed arm64 app ----------
app="$tmp_dir/Fake.app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Frameworks" "$app/Contents/Resources"
cat >"$tmp_dir/fake.c" <<'EOF'
#include <stdlib.h>
#include <unistd.h>
int main(void) {
const char *script = getenv("MT_FAKE_CHILD_SCRIPT");
if (script == NULL) return 64;
execl("/bin/sh", "sh", script, (char *)0);
return 127;
}
EOF
printf 'int fake_extension(void) { return 1; }\n' >"$tmp_dir/ext.c"
cc -arch arm64 -o "$app/Contents/MacOS/mtgodot-poc" "$tmp_dir/fake.c"
cc -arch arm64 -dynamiclib -o "$app/Contents/Frameworks/libmtgodot.macos.template_release.dylib" "$tmp_dir/ext.c"
printf 'fake pck\n' >"$app/Contents/Resources/mtgodot-poc.pck"
cat >"$app/Contents/Info.plist" <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleExecutable</key><string>mtgodot-poc</string>
<key>CFBundleIdentifier</key><string>test.mt.playable-gate</string>
<key>CFBundlePackageType</key><string>APPL</string>
</dict></plist>
EOF
codesign --force -s - "$app/Contents/Frameworks/libmtgodot.macos.template_release.dylib" >/dev/null 2>&1
codesign --force -s - "$app" >/dev/null 2>&1
# Local listener so the TCP precheck has a real endpoint; nothing reads from it.
node -e '
const server = require("node:net").createServer((socket) => socket.destroy());
server.listen(0, "127.0.0.1", () => console.log(server.address().port));
' >"$tmp_dir/port" &
listener_pid=$!
for _ in 1 2 3 4 5 6 7 8 9 10; do [ -s "$tmp_dir/port" ] && break; sleep 0.2; done
port="$(tr -d '\n' <"$tmp_dir/port")"
printf 'fixture\t127.0.0.1\t%s\t127.0.0.1\t%s\t1\t10\t0\n' "$port" "$port" >"$tmp_dir/serverlist.txt"
map_key="outdoortrent/metin2_map_trent"
mkdir -p "$tmp_dir/assets/$map_key"
printf 'ScriptType\tMapSetting\nBasePosition\t0\t0\nMapSize\t2\t2\n' >"$tmp_dir/assets/$map_key/Setting.txt"
node -e '
const [file, port, key] = process.argv.slice(1);
require("node:fs").writeFileSync(file, JSON.stringify({
schema_version: 1, scenario_id: "gate-fixture", protocol: "classic",
server: { server_index: 0, channel: 1, auth_host: "127.0.0.1", auth_port: Number(port), game_host: "127.0.0.1", game_port: Number(port) },
character_slot: 0, map_key: key, waypoints_cm: [[1000, 2000], [1600, 2000]],
allowed_mob_vnums: [101], allowed_drop_vnums: [19],
skill_cases: [{ case_id: "strike", skill_id: 5, target: "enemy", repeats: 2, required_evidence: ["cast_started", "damage"] }],
resolution: [1280, 720], loops: 1, timeout_seconds: 900,
}, null, 2));
' "$tmp_dir/scenario.local.json" "$port" "$map_key"
export MT_ACCOUNT="gate-fixture-account"
export MT_PASSWORD="gate-fixture-password"
export MT_FAKE_CHILD_SCRIPT="$repo/test/playable/fake_client.sh"
run_gate() { # scenario output [extra args...]
local scenario="$1" out="$2"
shift 2
MT_FAKE_SCENARIO="$scenario" bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
--output "$out" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" "$@" >"$out.stdout" 2>&1
}
report_status() { node -e 'try{process.stdout.write(require(process.argv[1]).status)}catch{process.stdout.write("MISSING")}' "$1/report.json"; }
expect_code 0 "$(code_of run_gate pass "$tmp_dir/run-pass")" "runner: PASS"
[ "$(report_status "$tmp_dir/run-pass")" = "PASS" ] && pass "runner: sealed report PASS" || fail "runner: sealed report not PASS"
expect_code 0 "$(code_of run_gate pass "$tmp_dir/run-pass-2")" "runner: second independent PASS"
expect_code 0 "$(code_of run_gate pass "$tmp_dir/run-full" --suite full)" "runner: full suite PASS"
expect_code 1 "$(code_of run_gate exit3 "$tmp_dir/run-exit3")" "runner: exit 3 after client PASS"
[ "$(report_status "$tmp_dir/run-exit3")" = "FAIL" ] && pass "runner: exit 3 sealed FAIL" || fail "runner: exit 3 not sealed FAIL"
expect_code 1 "$(code_of run_gate signal "$tmp_dir/run-signal")" "runner: child killed by signal"
expect_code 1 "$(code_of run_gate stale "$tmp_dir/run-stale")" "runner: stale run_id"
expect_code 1 "$(code_of run_gate no-report "$tmp_dir/run-noreport")" "runner: missing client report"
expect_code 1 "$(code_of run_gate rid "$tmp_dir/run-rid")" "runner: RID warning after PASS"
expect_code 1 "$(code_of run_gate leak "$tmp_dir/run-leak")" "runner: leak warning after PASS"
expect_code 1 "$(code_of run_gate missing-case "$tmp_dir/run-missing-case")" "runner: missing required case"
expect_code 1 "$(code_of run_gate missing-evidence "$tmp_dir/run-missing-evidence")" "runner: missing evidence file"
expect_code 2 "$(code_of run_gate blocked "$tmp_dir/run-blocked")" "runner: BLOCKED cases"
expect_code 0 "$(code_of run_gate print-secret "$tmp_dir/run-secret")" "runner: printed credentials are redacted before disk"
if grep -rqF -e "$MT_ACCOUNT" -e "$MT_PASSWORD" "$tmp_dir/run-secret" "$tmp_dir/run-secret.stdout"; then
fail "runner: credential literal reached the run directory"
else
pass "runner: no credential literal in run directory"
fi
if MT_PLAYABLE_VALIDATE_CONFIG="$PWD/test/playable/scenario.example.json" \
godot --headless --path project --script playable_config_gate.gd; then
echo "FAIL: empty scenario configuration was accepted" >&2
expect_code 124 "$(code_of run_gate hang "$tmp_dir/run-hang" --timeout-seconds 2)" "runner: TERM-ignoring child is killed"
if grep -qE '^TIMEOUT term_grace_s=1[0-9] killed=1$' "$tmp_dir/run-hang/gate.log"; then
pass "runner: TERM grace honoured before KILL"
else
fail "runner: timeout sequence: $(tr '\n' ' ' <"$tmp_dir/run-hang/gate.log" 2>/dev/null)"
fi
[ "$(report_status "$tmp_dir/run-hang")" = "FAIL" ] && pass "runner: timeout sealed FAIL" || fail "runner: timeout not sealed FAIL"
expect_code 1 "$(code_of run_gate fifo-holder "$tmp_dir/run-fifo")" "runner: grandchild holding the log pipe"
set +e
MT_FAKE_SCENARIO=exit3 bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
--output "$tmp_dir/run-pipe" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" 2>&1 | cat >/dev/null
piped=$?
set -e
expect_code 1 "$piped" "runner: exit code survives | cat under pipefail"
mkdir -p "$tmp_dir/run-nocreds"
code="$(code_of quiet env -u MT_ACCOUNT -u MT_PASSWORD MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" \
--config "$tmp_dir/scenario.local.json" --output "$tmp_dir/run-nocreds" --assets "$tmp_dir/assets" \
--serverlist "$tmp_dir/serverlist.txt" </dev/null)"
expect_code 2 "$code" "runner: no credentials and no TTY"
[ ! -e "$tmp_dir/run-nocreds/client.log" ] && pass "runner: client not started without credentials" || fail "runner: client started without credentials"
expect_code 2 "$(code_of run_gate pass "$tmp_dir/run-pass")" "runner: refuses a non-empty output directory"
expect_code 2 "$(code_of env MT_PLAYABLE_REQUIRED_ARCH=x86_64 bash -c 'MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$1" --config "$2" --output "$3" --assets "$4" --serverlist "$5" >/dev/null 2>&1' _ "$app" "$tmp_dir/scenario.local.json" "$tmp_dir/run-arch" "$tmp_dir/assets" "$tmp_dir/serverlist.txt")" "runner: missing required architecture"
printf '0\t127.0.0.1\t1\t127.0.0.1\t1\t1\t10\t0\n' >"$tmp_dir/serverlist-drift.txt"
expect_code 2 "$(code_of env bash -c 'MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$1" --config "$2" --output "$3" --assets "$4" --serverlist "$5" >/dev/null 2>&1' _ "$app" "$tmp_dir/scenario.local.json" "$tmp_dir/run-drift" "$tmp_dir/assets" "$tmp_dir/serverlist-drift.txt")" "runner: config address differs from serverlist"
[ ! -e "$tmp_dir/run-drift/client.log" ] && pass "runner: client not started on address drift" || fail "runner: client started on address drift"
cp -R "$app" "$tmp_dir/Tampered.app"
printf 'tampered\n' >>"$tmp_dir/Tampered.app/Contents/Resources/mtgodot-poc.pck"
expect_code 2 "$(code_of env bash -c 'MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$1" --config "$2" --output "$3" --assets "$4" --serverlist "$5" >/dev/null 2>&1' _ "$tmp_dir/Tampered.app" "$tmp_dir/scenario.local.json" "$tmp_dir/run-tampered" "$tmp_dir/assets" "$tmp_dir/serverlist.txt")" "runner: broken signature"
# ---------- release aggregation: explicit run list only ----------
release="$tmp_dir/release"
mkdir -p "$release"
cp -R "$tmp_dir/run-pass" "$release/run-1"
cp -R "$tmp_dir/run-pass-2" "$release/run-2"
cp -R "$tmp_dir/run-exit3" "$release/run-bad"
bash script/playable_build_info.sh "$app" >"$tmp_dir/candidate.json"
write_manifest() { # required runs-json [candidate-override-js]
node -e '
const fs = require("node:fs");
const [dir, required, runs, candidateFile, tweak] = process.argv.slice(1);
const candidate = JSON.parse(fs.readFileSync(candidateFile, "utf8"));
if (tweak) eval(tweak);
const list = JSON.parse(runs).map((name) => ({ run_id: JSON.parse(fs.readFileSync(`${dir}/${name}/report.json`, "utf8")).run_id, suite: "playable", path: name }));
fs.writeFileSync(`${dir}/release-manifest.json`, JSON.stringify({ schema_version: 1, candidate, required_runs: { playable: Number(required) }, runs: list }));
' "$release" "$1" "$2" "$tmp_dir/candidate.json" "${3:-}"
}
release_code() { code_of quiet node script/validate_playable_report.mjs --release-dir "$release"; }
write_manifest 2 '["run-1","run-2"]'
expect_code 0 "$(release_code)" "release: two listed PASS runs"
write_manifest 2 '["run-1"]'
expect_code 1 "$(release_code)" "release: unlisted PASS runs are never counted"
write_manifest 2 '["run-1","run-2","run-bad"]'
expect_code 1 "$(release_code)" "release: a listed FAIL run fails the release"
write_manifest 2 '["run-1","run-2"]' 'candidate.pck_sha256 = "e".repeat(64)'
expect_code 1 "$(release_code)" "release: runs from another candidate"
write_manifest 2 '["run-1","run-1"]'
expect_code 1 "$(release_code)" "release: the same run listed twice"
node -e 'const fs=require("fs");const f=process.argv[1];const m=JSON.parse(fs.readFileSync(f));m.runs[0].path="../run-pass";fs.writeFileSync(f,JSON.stringify(m));' "$release/release-manifest.json"
expect_code 1 "$(release_code)" "release: run path escapes the release directory"
mkdir -p "$release/run-blocked"
printf 'BLOCKED 缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端\n' >"$release/run-blocked/gate.log"
node -e 'const fs=require("fs");const f=process.argv[1];const m=JSON.parse(fs.readFileSync(f));m.runs=[m.runs[1],{run_id:"run-blocked-unsealed",suite:"playable",path:"run-blocked"}];m.required_runs={playable:2};fs.writeFileSync(f,JSON.stringify(m));' "$release/release-manifest.json"
expect_code 2 "$(release_code)" "release: precondition-blocked run is BLOCKED, not PASS"
# ---------- batch entry: manifest written from the runs it just made ----------
expect_code 0 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/playable_test.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
--output "$tmp_dir/batch" --suite playable --repeat 2 --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt")" "batch: two runs aggregate to PASS"
batch_runs="$(node -e 'const m=require(process.argv[1]);process.stdout.write(m.runs.map((r)=>r.path).join(","))' "$tmp_dir/batch/release-manifest.json")"
[ "$batch_runs" = "run-1,run-2" ] && pass "batch: manifest lists exactly its own runs" || fail "batch: manifest runs $batch_runs"
expect_code 1 "$(code_of quiet env MT_FAKE_SCENARIO=rid bash script/playable_test.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
--output "$tmp_dir/batch-rid" --suite playable --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt")" "batch: a failing run fails the batch"
expect_code 2 "$(code_of quiet env -u MT_ACCOUNT -u MT_PASSWORD bash script/playable_test.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
--output "$tmp_dir/batch-nocreds" </dev/null)" "batch: no credentials and no TTY"
# ---------- MAP-02 forest render runner (offline, same exit gate) ----------
run_forest() { # scenario output [extra args...]
local scenario="$1" out="$2"
shift 2
MT_FAKE_SCENARIO="$scenario" bash script/forest_map_render_test.sh --app "$app" --maps "$map_key" --output "$out" "$@" >"$out.stdout" 2>&1
}
expect_code 0 "$(code_of run_forest no-creds "$tmp_dir/forest-pass")" "forest: PASS without credentials in the child"
forest_cases="$(node -e 'process.stdout.write(require(process.argv[1]).cases.join(","))' "$tmp_dir/forest-pass/required-cases.json")"
[ "$forest_cases" = "MAP-FOREST-CONFIG,MAP-FOREST-LOAD-metin2_map_trent,MAP-FOREST-VP-metin2_map_trent-flat,MAP-FOREST-VP-metin2_map_trent-slope,MAP-FOREST-VP-metin2_map_trent-dense,MAP-FOREST-VP-metin2_map_trent-warp,MAP-FOREST-MOTION-metin2_map_trent" ] \
&& pass "forest: required cases follow required_case_ids()" || fail "forest: required cases $forest_cases"
grep -q "人工签核" "$tmp_dir/forest-pass.stdout" && pass "forest: manual screenshot sign-off stays pending" || fail "forest: sign-off reminder missing"
expect_code 2 "$(code_of run_forest blocked "$tmp_dir/forest-blocked")" "forest: BLOCKED viewpoints are not releasable"
expect_code 1 "$(code_of run_forest missing-case "$tmp_dir/forest-missing-case")" "forest: missing required case"
expect_code 1 "$(code_of run_forest leak "$tmp_dir/forest-leak")" "forest: leak warning after PASS"
expect_code 124 "$(code_of run_forest hang "$tmp_dir/forest-hang" --timeout-seconds 2)" "forest: TERM-ignoring child is killed"
mkdir -p "$tmp_dir/forest-nomap"
expect_code 2 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/forest_map_render_test.sh --app "$app" --output "$tmp_dir/forest-nomap")" "forest: no map key"
[ ! -e "$tmp_dir/forest-nomap/client.log" ] && pass "forest: client not started without a map" || fail "forest: client started without a map"
expect_code 2 "$(code_of run_forest pass "$tmp_dir/forest-races" --races 2301,abc)" "forest: invalid race list"
expect_code 2 "$(code_of run_forest pass "$tmp_dir/forest-pass")" "forest: refuses a non-empty output directory"
# ---------- STB-01 soak runner (offline; fake client, no real 2 h run) ----------
soak_config() { # output-file server-port [faults-js]
node -e '
const fs = require("node:fs");
const [base, out, port, faults] = process.argv.slice(1);
const c = JSON.parse(fs.readFileSync(base, "utf8"));
c.timeout_seconds = 8100;
c.server.auth_port = Number(port); c.server.game_port = Number(port);
c.soak = { duration_seconds: 7200, rest_seconds: 30, warmup_rounds: 1, min_rounds: 10, reconnects: 10, exits: 10,
resolutions: { sizes: [[1280, 720], [1440, 900], [1600, 1000]], switches_per_size: 10 },
warp: { status: "unconfirmed" }, faults: { status: "unconfirmed" } };
if (faults) c.soak.faults = JSON.parse(faults);
fs.writeFileSync(out, JSON.stringify(c, null, 2));
' "$tmp_dir/scenario.local.json" "$1" "$2" "${3:-}"
}
soak_config "$tmp_dir/soak.local.json" "$port"
run_soak_gate() { # scenario output config serverlist [extra args...]
local scenario="$1" out="$2" config="$3" list="$4"
shift 4
MT_FAKE_SCENARIO="$scenario" bash script/run_client_gate.sh --app "$app" --config "$config" --output "$out" \
--assets "$tmp_dir/assets" --serverlist "$list" --suite soak --allow-gameplay "$@" >"$out.stdout" 2>&1
}
mkdir -p "$tmp_dir/soak-short"
expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-short" "$tmp_dir/soak.local.json" "$tmp_dir/serverlist.txt" --timeout-seconds 900)" "soak runner: timeout below duration+900"
[ ! -e "$tmp_dir/soak-short/client.log" ] && pass "soak runner: client not started with a short timeout" || fail "soak runner: client started with a short timeout"
mkdir -p "$tmp_dir/soak-noblock"
expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-noblock" "$tmp_dir/scenario.local.json" "$tmp_dir/serverlist.txt" --timeout-seconds 8100)" "soak runner: scenario without a soak block"
[ ! -e "$tmp_dir/soak-noblock/client.log" ] && pass "soak runner: client not started without a soak block" || fail "soak runner: client started without a soak block"
expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-run" "$tmp_dir/soak.local.json" "$tmp_dir/serverlist.txt" --timeout-seconds 8100)" "soak runner: fake client without soak rounds is BLOCKED, not PASS"
soak_seal="$(node -e '
const fs = require("node:fs");
const dir = process.argv[1];
const r = JSON.parse(fs.readFileSync(`${dir}/report.json`, "utf8"));
const memory = (r.runner_cases || []).find((c) => c.id === "STB-MEMORY-01");
const rows = fs.readFileSync(`${dir}/rss.jsonl`, "utf8").trim().split("\n").filter(Boolean).length;
process.stdout.write([r.status, memory ? memory.status : "none", rows > 0 ? "rss" : "no-rss", r.exit_gate.checked].join(","));
' "$tmp_dir/soak-run" 2>/dev/null || echo unreadable)"
[ "$soak_seal" = "BLOCKED,BLOCKED,rss,true" ] && pass "soak runner: RSS sampled for the child and STB-MEMORY-01 sealed" || fail "soak runner: seal $soak_seal"
[ ! -e "$tmp_dir/soak-run/fault-proxy.jsonl" ] && pass "soak runner: no fault proxy while faults are unconfirmed" || fail "soak runner: proxy started for unconfirmed faults"
# Confirmed faults: client points at a loopback proxy port, proxy forwards to the local listener.
proxy_port="$(node -e 'const s=require("node:net").createServer();s.listen(0,"127.0.0.1",()=>{const p=s.address().port;s.close(()=>process.stdout.write(String(p)));})')"
printf 'fixture\t127.0.0.1\t%s\t127.0.0.1\t%s\t1\t10\t0\n' "$proxy_port" "$proxy_port" >"$tmp_dir/serverlist-proxy.txt"
soak_config "$tmp_dir/soak-proxy.local.json" "$proxy_port" "{\"status\":\"confirmed\",\"mode\":\"local_proxy\",\"per_type\":1,\"unreachable_seconds\":5,\"upstream\":{\"auth_host\":\"127.0.0.1\",\"auth_port\":$port,\"game_host\":\"127.0.0.1\",\"game_port\":$port}}"
expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-proxy" "$tmp_dir/soak-proxy.local.json" "$tmp_dir/serverlist-proxy.txt" --timeout-seconds 8100)" "soak runner: confirmed faults run through the loopback proxy"
proxy_actions="$(node -e 'const rows=require("node:fs").readFileSync(process.argv[1],"utf8").trim().split("\n").map((l)=>JSON.parse(l).action);process.stdout.write(String(rows[0]==="listening"&&rows.at(-1)==="stopped"))' "$tmp_dir/soak-proxy/fault-proxy.jsonl" 2>/dev/null || echo false)"
[ "$proxy_actions" = "true" ] && pass "soak runner: proxy listened before the client and stopped after it" || fail "soak runner: proxy log incomplete"
proxy_pid="$(sed -n 's/^FAULT PROXY pid=\([0-9]*\) .*/\1/p' "$tmp_dir/soak-proxy/gate.log")"
if [ -n "$proxy_pid" ] && ! kill -0 "$proxy_pid" 2>/dev/null; then pass "soak runner: proxy process is gone after the run"; else fail "soak runner: proxy pid [$proxy_pid] still alive or unknown"; fi
proxy_playable() { # output: an exit run (suite playable) with the same confirmed-faults config still reaches the server
MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/soak-proxy.local.json" --output "$1" \
--assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist-proxy.txt" --allow-gameplay >"$1.stdout" 2>&1
}
expect_code 0 "$(code_of proxy_playable "$tmp_dir/soak-proxy-exit")" "soak runner: playable exit run behind the confirmed proxy"
grep -q '"action":"stopped"' "$tmp_dir/soak-proxy-exit/fault-proxy.jsonl" 2>/dev/null && [ ! -e "$tmp_dir/soak-proxy-exit/rss.jsonl" ] \
&& pass "soak runner: exit run forwards through the proxy without soak sampling" || fail "soak runner: exit run proxy/sampler state"
node -e 'const fs=require("fs");const c=JSON.parse(fs.readFileSync(process.argv[1]));c.timeout_seconds=900;fs.writeFileSync(process.argv[2],JSON.stringify(c));' "$tmp_dir/soak.local.json" "$tmp_dir/soak-short-timeout.local.json"
expect_code 0 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/soak-short-timeout.local.json" \
--output "$tmp_dir/soak-block-playable" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt")" "soak runner: playable suite ignores the soak-only timeout margin"
bad_soak="$tmp_dir/soak-bad-playable.local.json"
node -e 'const fs=require("fs");const c=JSON.parse(fs.readFileSync(process.argv[1]));c.soak.faults.upstream.auth_port=70000;fs.writeFileSync(process.argv[2],JSON.stringify(c));' "$tmp_dir/soak-proxy.local.json" "$bad_soak"
mkdir -p "$tmp_dir/soak-bad-playable"
expect_code 2 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" --config "$bad_soak" --output "$tmp_dir/soak-bad-playable" \
--assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist-proxy.txt")" "soak runner: an invalid soak block is rejected for every suite"
grep -q "soak.faults.upstream.auth_port" "$tmp_dir/soak-bad-playable/config.log" 2>/dev/null && [ ! -e "$tmp_dir/soak-bad-playable/client.log" ] \
&& pass "soak runner: config gate names the invalid upstream before any client" || fail "soak runner: invalid soak block not caught by the config gate"
mkdir -p "$tmp_dir/soak-proxy-down"
soak_config "$tmp_dir/soak-proxy-down.local.json" "$proxy_port" "{\"status\":\"confirmed\",\"mode\":\"local_proxy\",\"per_type\":1,\"unreachable_seconds\":5,\"upstream\":{\"auth_host\":\"127.0.0.1\",\"auth_port\":1,\"game_host\":\"127.0.0.1\",\"game_port\":1}}"
expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-proxy-down" "$tmp_dir/soak-proxy-down.local.json" "$tmp_dir/serverlist-proxy.txt" --timeout-seconds 8100)" "soak runner: unreachable fault-proxy upstream"
[ ! -e "$tmp_dir/soak-proxy-down/client.log" ] && pass "soak runner: client not started when the upstream is down" || fail "soak runner: client started with upstream down"
# ---------- STB-01 public entry: script/playable_soak.sh ----------
run_soak() { # scenario output [extra args...]
local scenario="$1" out="$2"
shift 2
MT_FAKE_SCENARIO="$scenario" bash script/playable_soak.sh --app "$app" --config "$tmp_dir/soak.local.json" --output "$out" \
--assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" "$@" </dev/null >"$out.stdout" 2>&1
}
expect_code 0 "$(code_of quiet bash script/playable_soak.sh --help)" "soak: --help"
expect_code 2 "$(code_of quiet bash script/playable_soak.sh --app "$app" </dev/null)" "soak: no config"
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-nogameplay")" "soak: refuses to start without --allow-gameplay"
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-duration" --allow-gameplay --duration-seconds 60)" "soak: --duration-seconds must match the config"
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-exits" --allow-gameplay --exits 9)" "soak: fewer than 10 exit runs"
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-timeout" --allow-gameplay --timeout-seconds 7300)" "soak: timeout below duration+900"
expect_code 2 "$(code_of quiet env MT_FAKE_SCENARIO=soak-pass bash script/playable_soak.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
--output "$tmp_dir/soak-noblock-entry" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" --allow-gameplay </dev/null)" "soak: config without a soak block"
for dir in soak-nogameplay soak-duration soak-exits soak-timeout soak-noblock-entry; do
if [ -n "$(find "$tmp_dir/$dir" -name client.log 2>/dev/null)" ]; then fail "soak: $dir started a client"; fi
done
expect_code 2 "$(code_of quiet env -u MT_ACCOUNT -u MT_PASSWORD MT_FAKE_SCENARIO=soak-pass bash script/playable_soak.sh --app "$app" \
--config "$tmp_dir/soak.local.json" --output "$tmp_dir/soak-nocreds" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" --allow-gameplay </dev/null)" "soak: no credentials and no TTY"
expect_code 1 "$(code_of run_soak rid "$tmp_dir/soak-exitfail" --allow-gameplay)" "soak: a failing exit run fails the release"
[ ! -e "$tmp_dir/soak-exitfail/soak-1/client.log" ] && grep -q "^BLOCKED not started: exit-1" "$tmp_dir/soak-exitfail/soak-1/gate.log" 2>/dev/null \
&& pass "soak: 2 h soak not started after a failed exit run" || fail "soak: soak run started after a failed exit run"
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-batch" --allow-gameplay)" "soak: fake batch stays BLOCKED without real soak rounds"
soak_runs="$(node -e 'const m=require(process.argv[1]);process.stdout.write(m.runs.map((r)=>`${r.path}:${r.suite}`).join(",")+"|"+JSON.stringify(m.required_runs))' "$tmp_dir/soak-batch/release-manifest.json" 2>/dev/null || echo missing)"
expected_runs="$(for i in 1 2 3 4 5 6 7 8 9 10; do printf 'exit-%s:playable,' "$i"; done)soak-1:soak|{\"playable\":10,\"soak\":1}"
[ "$soak_runs" = "$expected_runs" ] && pass "soak: manifest lists 10 exit runs and one soak run" || fail "soak: manifest $soak_runs"
exit_passes="$(node -e 'const r=require(process.argv[1]);process.stdout.write(String(r.counts.playable.passed)+"/"+r.status)' "$tmp_dir/soak-batch/release-report.json" 2>/dev/null || echo missing)"
[ "$exit_passes" = "10/BLOCKED" ] && pass "soak: 10 independent exit runs PASS, release BLOCKED by memory evidence" || fail "soak: release $exit_passes"
if grep -rqF -e "$MT_ACCOUNT" -e "$MT_PASSWORD" "$tmp_dir/soak-batch" "$tmp_dir/soak-batch.stdout"; then
fail "soak: credential literal reached the batch directory"
else
pass "soak: no credential literal in the batch directory"
fi
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-batch" --allow-gameplay)" "soak: refuses a non-empty output directory"
if [ "$failures" -ne 0 ]; then
echo "playable_gate_test: FAIL ($failures)" >&2
exit 1
fi
echo "playable_gate_test: PASS"
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env bash
# STB-01 public entry: 2 h wall-clock soak plus independent normal-exit runs.
#
# Layout under --output (must be empty):
# exit-<n>/ N independent client processes, suite playable, each through
# run_client_gate.sh (normal quit, exit 0, no leak/RID warnings)
# soak-1/ one client process, suite soak: move/combat/pickup rounds until the
# wall-clock duration, active reconnects, resolution switches, and —
# only when the scenario confirms them — portal warps and real
# transport faults through the 127.0.0.1 fault proxy; RSS is sampled
# for that child PID and sealed as STB-MEMORY-01
# release-manifest.json listing exactly these runs; aggregated with
# validate_playable_report.mjs --release-dir (no historical reports are scanned).
#
# Exit runs go first; if one is not PASS the remaining runs (including the 2 h soak)
# are not started and are recorded as BLOCKED so the release cannot pass.
# Credentials only from MT_ACCOUNT/MT_PASSWORD or one hidden TTY prompt.
# Do not add `set -x` to this script. Never pkill; never touch routes/firewall/caches.
#
# exit: 0 PASS, 1 FAIL, 2 BLOCKED (config/precondition/unconfirmed environment), 124 timeout.
set -euo pipefail
set +x
cd "$(dirname "$0")/.."
repo="$PWD"
app="${MT_PLAYABLE_APP:-}"
config=""
output=""
duration=""
exits=""
timeout=""
exit_timeout="900"
allow=0
assets=""
serverlist="${MT_PLAYABLE_SERVERLIST:-}"
usage() {
cat <<'EOF'
用法: script/playable_soak.sh --app APP --config CONFIG --allow-gameplay [选项]
--app APP 候选导出包(所有 run 必须同一哈希)
--config CONFIG 场景 JSON(含 soak 块;不含凭据)
--output DIR 本批次根目录,必须不存在或为空(默认 build/playable/soak-<时间>-<pid>
--duration-seconds N 墙钟时长,必须等于配置 soak.duration_seconds(默认取配置,>= 7200
--exits N 独立正常退出运行次数(默认配置 soak.exits>= 10
--timeout-seconds N soak 进程墙钟超时(默认 duration+900,不得更小)
--exit-timeout-seconds N 每个退出运行的墙钟超时,默认 900
--assets DIR 资源预检根目录(透传 run_client_gate.sh
--serverlist FILE 测试模式 serverlist 覆盖(透传)
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取请求(不给则只校验配置,返回 2,不启动客户端)
环境变量:
MT_ACCOUNT / MT_PASSWORD 测试账号(未设置且 stdin 是终端时隐藏输入一次)
说明:
切图(soak.warp)和真实断网(soak.faults)须由环境负责人确认后在配置里标 confirmed;
未确认时对应用例为 BLOCKED,不用主动重连或直接传送替代。
退出码: 0 PASS / 1 FAIL / 2 BLOCKED / 124 超时
EOF
}
blocked() { echo "PLAYABLE SOAK: BLOCKED $*" >&2; exit 2; }
positive() { [[ "$1" =~ ^[1-9][0-9]*$ ]]; }
while [ "$#" -gt 0 ]; do
case "$1" in
--app) app="${2:-}"; shift 2 ;;
--config) config="${2:-}"; shift 2 ;;
--output) output="${2:-}"; shift 2 ;;
--duration-seconds) duration="${2:-}"; shift 2 ;;
--exits) exits="${2:-}"; shift 2 ;;
--timeout-seconds) timeout="${2:-}"; shift 2 ;;
--exit-timeout-seconds) exit_timeout="${2:-}"; shift 2 ;;
--assets) assets="${2:-}"; shift 2 ;;
--serverlist) serverlist="${2:-}"; shift 2 ;;
--allow-gameplay) allow=1; shift ;;
--help) usage; exit 0 ;;
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
esac
done
[ -n "$app" ] && [ -d "$app" ] || blocked "需要 --app 指向候选导出包"
[ -n "$config" ] && [ -f "$config" ] || blocked "需要 --config CONFIG"
config="$(cd "$(dirname "$config")" && pwd)/$(basename "$config")"
for pair in "duration-seconds:$duration" "exits:$exits" "timeout-seconds:$timeout"; do
value="${pair#*:}"
if [ -n "$value" ] && ! positive "$value"; then blocked "--${pair%%:*} 必须是正整数"; fi
done
positive "$exit_timeout" || blocked "--exit-timeout-seconds 必须是正整数"
# Config validation before any prompt or process: the same gate the runner uses.
if [ -z "$assets" ]; then
if [ -d "$app/Contents/Resources/assets" ]; then assets="$app/Contents/Resources/assets"; else assets="$repo/assets"; fi
fi
if ! config_log="$(env MT_PLAYABLE_VALIDATE_CONFIG="$config" MT_PLAYABLE_SUITE=soak MT_PLAYABLE_ASSETS="$assets" \
MT_PLAYABLE_SERVERLIST="$serverlist" "${MT_GODOT:-godot}" --headless --path project --script playable_config_gate.gd 2>&1)"; then
printf '%s\n' "$config_log" | grep '^CONFIG:' >&2 || true
blocked "配置/资源预检失败(suite soak"
fi
read -r cfg_duration cfg_exits <<<"$(node -e '
const s = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).soak;
process.stdout.write(`${s.duration_seconds} ${s.exits}`);
' "$config")"
if [ -z "$duration" ]; then duration="$cfg_duration"; fi
[ "$duration" -eq "$cfg_duration" ] || blocked "--duration-seconds $duration 与配置 soak.duration_seconds $cfg_duration 不一致;请改配置而不是覆盖"
if [ -z "$exits" ]; then exits="$cfg_exits"; fi
[ "$exits" -ge 10 ] || blocked "--exits 至少 10"
minimum_timeout=$((duration + 900))
if [ -z "$timeout" ]; then timeout="$minimum_timeout"; fi
[ "$timeout" -ge "$minimum_timeout" ] || blocked "--timeout-seconds 不得小于 duration+900${minimum_timeout}"
# Without explicit consent only the configuration is checked; no client, no gameplay request.
[ "$allow" -eq 1 ] || blocked "配置校验通过;soak 需要 --allow-gameplay(专用测试账号上的移动/战斗/拾取),未启动客户端"
root="$output"
if [ -z "$root" ]; then root="$repo/build/playable/soak-$(date +%Y%m%d-%H%M%S)-$$"; fi
if [ -e "$root" ] && [ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
blocked "输出目录已有内容,拒绝混入历史运行:$root"
fi
mkdir -p "$root"
root="$(cd "$root" && pwd)"
if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then
if [ -t 0 ]; then
if [ -z "${MT_ACCOUNT:-}" ]; then read -r -s -p "测试账号: " MT_ACCOUNT; echo >&2; fi
if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "测试密码: " MT_PASSWORD; echo >&2; fi
fi
fi
[ -n "${MT_ACCOUNT:-}" ] && [ -n "${MT_PASSWORD:-}" ] || blocked "缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端"
export MT_ACCOUNT MT_PASSWORD
bash script/playable_build_info.sh "$app" >"$root/candidate.json"
runs_json="[]"
timed_out=0
stop_reason=""
list_run() { # name suite
runs_json="$(node -e '
const fs = require("node:fs");
const [runs, dir, name, suite] = process.argv.slice(1);
let runId = `${name}-unsealed`;
try { runId = JSON.parse(fs.readFileSync(`${dir}/report.json`, "utf8")).run_id || runId; } catch {}
const list = JSON.parse(runs);
list.push({ run_id: runId, suite, path: name });
process.stdout.write(JSON.stringify(list));
' "$runs_json" "$root/$1" "$1" "$2")"
}
run_one() { # name suite timeout
local name="$1" run_suite="$2" run_timeout="$3" code
if [ -n "$stop_reason" ]; then
mkdir -p "$root/$name"
echo "BLOCKED not started: $stop_reason" >"$root/$name/gate.log"
list_run "$name" "$run_suite"
return
fi
local args=(--app "$app" --config "$config" --output "$root/$name" --suite "$run_suite" --timeout-seconds "$run_timeout" --allow-gameplay)
if [ -n "$assets" ]; then args+=(--assets "$assets"); fi
if [ -n "$serverlist" ]; then args+=(--serverlist "$serverlist"); fi
set +e
bash script/run_client_gate.sh "${args[@]}" </dev/null
code=$?
set -e
if [ "$code" -eq 124 ]; then timed_out=1; fi
if [ "$code" -ne 0 ] && [ "$run_suite" = "playable" ]; then stop_reason="$name exit $code"; fi
list_run "$name" "$run_suite"
}
for index in $(seq 1 "$exits"); do
run_one "exit-$index" playable "$exit_timeout"
done
run_one soak-1 soak "$timeout"
node -e '
const fs = require("node:fs");
const [root, exits, runs] = process.argv.slice(1);
const candidate = JSON.parse(fs.readFileSync(`${root}/candidate.json`, "utf8"));
fs.writeFileSync(`${root}/release-manifest.json`, JSON.stringify({
schema_version: 1, candidate, required_runs: { playable: Number(exits), soak: 1 }, runs: JSON.parse(runs),
}, null, 2) + "\n");
' "$root" "$exits" "$runs_json"
set +e
node script/validate_playable_report.mjs --release-dir "$root"
release_code=$?
set -e
if [ -f "$root/soak-1/report.json" ]; then
node -e '
const r = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
for (const c of [...(r.cases || []).filter((c) => c.id.startsWith("STB-")), ...(r.runner_cases || [])]) console.log(` ${c.id} ${c.status} ${c.reason || ""}`);
' "$root/soak-1/report.json" || true
fi
if [ "$timed_out" -eq 1 ]; then echo "PLAYABLE SOAK: TIMEOUT root=$root" >&2; exit 124; fi
case "$release_code" in
0) echo "PLAYABLE SOAK: PASS exits=$exits duration=${duration}s root=$root" ;;
2) echo "PLAYABLE SOAK: BLOCKED root=$root" >&2 ;;
*) echo "PLAYABLE SOAK: FAIL root=$root" >&2; release_code=1 ;;
esac
exit "$release_code"
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env node
/* STB-01 §9.1 external memory probe and leak gate.
*
* playable_soak_metrics.mjs --sample-rss --pid PID --output rss.jsonl [--interval-ms 1000]
* Appends {wall_ms, rss_kib} for exactly one PID (the runner's own child) via
* `ps -o stat=,rss= -p PID` once per interval and exits when that PID is gone or a zombie. It
* never signals the PID. A failed read is written as rss_kib null, not 0.
*
* playable_soak_metrics.mjs --seal --report report.json --rss rss.jsonl --events events.jsonl
* [--warmup-rounds 1] [--min-rounds 10]
* Reads the client's soak_rest start/end events (payload.round, payload.wall_ms),
* takes the RSS low water inside each >=30 s rest window, applies the leak rule
* and writes report.memory + runner_cases[STB-MEMORY-01] back into the sealed
* report. The verdict can only downgrade the report status.
*
* Leak rule: after warm-up, FAIL when the low water grew in >=5 consecutive rounds
* AND median(last 5) - median(first 5) > max(50 MiB, 5% of median(first 5)).
* Below the threshold is not proof that there is no long-term leak.
*
* exit: 0 PASS, 1 FAIL, 2 BLOCKED or usage error. */
import fs from 'node:fs';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const REST_MIN_MS = 30000;
const STREAK = 5;
const ABS_THRESHOLD_KIB = 50 * 1024;
const PCT_THRESHOLD = 0.05;
const NOTE = '阈值以下不能证明长期无泄漏;GPU 内存不可得(null/unavailable),不据此判断显存。';
const CASE_ID = 'STB-MEMORY-01';
const RANK = { PASS: 0, BLOCKED: 1, FAIL: 2 };
export function median(values) {
const sorted = [...values].sort((a, b) => a - b);
if (sorted.length === 0) return null;
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
export function parseRss(text) {
const samples = [];
let badLines = 0;
for (const line of String(text).split('\n')) {
if (line.trim() === '') continue;
try {
const row = JSON.parse(line);
if (!Number.isFinite(row.wall_ms) || !(row.rss_kib === null || Number.isInteger(row.rss_kib))) badLines += 1;
else samples.push({ wall_ms: row.wall_ms, rss_kib: row.rss_kib });
} catch { badLines += 1; }
}
return { samples, bad_lines: badLines };
}
/** Pair soak_rest start/end events per round. Unpaired or reordered events are errors. */
export function restWindows(eventsText) {
const open = new Map();
const windows = [];
const errors = [];
for (const line of String(eventsText).split('\n')) {
if (line.trim() === '') continue;
let event;
try { event = JSON.parse(line); } catch { continue; }
if (event?.kind !== 'soak_rest') continue;
const { round, state, wall_ms: wallMs } = event.payload ?? {};
if (!Number.isInteger(round) || !Number.isFinite(wallMs)) { errors.push('soak_rest event without integer round/wall_ms'); continue; }
if (state === 'start') {
if (open.has(round)) errors.push(`round ${round}: rest started twice`);
open.set(round, wallMs);
} else if (state === 'end') {
if (!open.has(round)) { errors.push(`round ${round}: rest ended without a start`); continue; }
const start = open.get(round);
open.delete(round);
if (wallMs < start) errors.push(`round ${round}: rest ends before it starts`);
else windows.push({ round, start_ms: start, end_ms: wallMs, rest_ms: wallMs - start });
}
}
for (const round of open.keys()) errors.push(`round ${round}: rest never ended`);
windows.sort((a, b) => a.round - b.round);
return { windows, errors };
}
export function roundLowWaters(windows, samples) {
return windows.map((window) => {
const inside = samples.filter((s) => s.wall_ms >= window.start_ms && s.wall_ms <= window.end_ms && Number.isInteger(s.rss_kib));
// At least one sample per two seconds of rest; a sparse window is not a measured low water.
const needed = Math.max(1, Math.floor(window.rest_ms / 2000));
return {
round: window.round,
rest_ms: window.rest_ms,
samples: inside.length,
low_water_kib: inside.length >= needed ? Math.min(...inside.map((s) => s.rss_kib)) : null,
};
});
}
export function leakVerdict(lowWaters, { warmupRounds = 1, minRounds = 10 } = {}) {
const verdict = {
status: 'BLOCKED', reason: '', unit: 'KiB', warmup_rounds: warmupRounds, rounds_total: lowWaters.length, rounds_analysed: 0,
first5_median_kib: null, last5_median_kib: null, growth_kib: null, threshold_kib: null, max_growth_streak: 0,
rule: `FAIL iff >=${STREAK} consecutive growing rounds AND last5 median - first5 median > max(50MiB, 5%)`,
warnings: [], note: NOTE,
};
const short = lowWaters.find((row) => row.rest_ms < REST_MIN_MS);
if (short) return { ...verdict, status: 'FAIL', reason: `round ${short.round}: rest ${short.rest_ms} ms is shorter than 30 s` };
const rows = lowWaters.slice(warmupRounds);
verdict.rounds_analysed = rows.length;
if (rows.length < minRounds) return { ...verdict, reason: `need at least ${minRounds} rounds after warm-up, got ${rows.length}` };
const missing = rows.find((row) => row.low_water_kib === null);
if (missing) return { ...verdict, reason: `round ${missing.round}: no RSS low water (sampler gap)` };
const values = rows.map((row) => row.low_water_kib);
let streak = 0;
for (let i = 1; i < values.length; i += 1) {
streak = values[i] > values[i - 1] ? streak + 1 : 0;
verdict.max_growth_streak = Math.max(verdict.max_growth_streak, streak);
}
const first = Math.round(median(values.slice(0, STREAK)));
const last = Math.round(median(values.slice(-STREAK)));
verdict.first5_median_kib = first;
verdict.last5_median_kib = last;
verdict.growth_kib = last - first;
verdict.threshold_kib = Math.max(ABS_THRESHOLD_KIB, Math.ceil(PCT_THRESHOLD * first));
const overThreshold = verdict.growth_kib > verdict.threshold_kib;
const streakHit = verdict.max_growth_streak >= STREAK;
if (overThreshold && streakHit) {
verdict.status = 'FAIL';
verdict.reason = `low water grew ${verdict.max_growth_streak} rounds in a row and +${verdict.growth_kib} KiB > ${verdict.threshold_kib} KiB; analyse before release`;
return verdict;
}
if (overThreshold) verdict.warnings.push(`median growth +${verdict.growth_kib} KiB exceeds the threshold without ${STREAK} consecutive growing rounds; needs analysis`);
verdict.status = 'PASS';
verdict.reason = 'no leak signal at the gate threshold';
return verdict;
}
export function sealMemory(report, { eventsText, rssText, warmupRounds = 1, minRounds = 10 }) {
const rss = parseRss(rssText ?? '');
const rest = restWindows(eventsText ?? '');
const rounds = roundLowWaters(rest.windows, rss.samples);
let verdict = leakVerdict(rounds, { warmupRounds, minRounds });
if (rest.errors.length > 0) verdict = { ...verdict, status: 'FAIL', reason: `rest events: ${rest.errors.slice(0, 5).join('; ')}` };
else if (rest.windows.length === 0) verdict = { ...verdict, status: 'BLOCKED', reason: 'no soak_rest windows in events' };
// The client's own MEMORY_STATIC snapshots stay separate from the external RSS.
const godot = report.memory?.godot ?? report.memory ?? null;
report.memory = {
sampler: 'external ps -o stat=,rss= on the runner-held child PID, 1 Hz',
unit: 'KiB',
sample_count: rss.samples.length,
samples_null: rss.samples.filter((s) => s.rss_kib === null).length,
bad_lines: rss.bad_lines,
rounds,
gpu_memory_kib: null,
gpu_memory_status: 'unavailable',
godot,
verdict,
};
report.runner_cases = (Array.isArray(report.runner_cases) ? report.runner_cases : []).filter((c) => c?.id !== CASE_ID);
report.runner_cases.push({ id: CASE_ID, status: verdict.status, reason: verdict.reason });
if (!VALID_STATUS(report.status)) report.status = 'FAIL';
if (RANK[verdict.status] > RANK[report.status]) report.status = verdict.status;
report.exit_gate = report.exit_gate && typeof report.exit_gate === 'object' ? report.exit_gate : { checked: false, errors: [] };
if (!Array.isArray(report.exit_gate.errors)) report.exit_gate.errors = [];
if (verdict.status !== 'PASS') report.exit_gate.errors.push(`${CASE_ID}: ${verdict.status} ${verdict.reason}`);
return { report, code: { PASS: 0, FAIL: 1, BLOCKED: 2 }[report.status] ?? 1 };
}
function VALID_STATUS(status) { return Object.hasOwn(RANK, status); }
function usage(stream = process.stderr) {
stream.write('usage: playable_soak_metrics.mjs --sample-rss --pid PID --output rss.jsonl [--interval-ms 1000]\n'
+ ' playable_soak_metrics.mjs --seal --report report.json --rss rss.jsonl --events events.jsonl [--warmup-rounds 1] [--min-rounds 10]\n');
}
function argsOf(argv) {
const out = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (['--sample-rss', '--seal', '--help'].includes(arg)) out[arg.slice(2).replace('-', '_')] = true;
else if (arg.startsWith('--') && i + 1 < argv.length) out[arg.slice(2).replaceAll('-', '_')] = argv[++i];
else throw new Error(`bad argument: ${arg}`);
}
return out;
}
function readRss(pid) {
return new Promise((resolve) => {
execFile('ps', ['-o', 'stat=,rss=', '-p', String(pid)], { timeout: 5000 }, (error, stdout) => {
if (error) { resolve({ alive: false }); return; }
const [stat = '', rss = ''] = String(stdout).trim().split(/\s+/);
// An exited child the runner has not reaped yet is a zombie: it is gone, not 0 KiB.
if (stat.startsWith('Z')) { resolve({ alive: false }); return; }
const value = Number.parseInt(rss, 10);
resolve({ alive: true, rss_kib: Number.isInteger(value) && value > 0 ? value : null });
});
});
}
async function sampleRss(options) {
const pid = Number(options.pid);
const interval = Number(options.interval_ms ?? 1000);
if (!Number.isInteger(pid) || pid <= 0 || !options.output || !Number.isInteger(interval) || interval < 100) { usage(); return 2; }
let stop = false;
process.on('SIGTERM', () => { stop = true; });
process.on('SIGINT', () => { stop = true; });
while (!stop) {
const tick = Date.now();
const row = await readRss(pid);
if (!row.alive) break;
fs.appendFileSync(options.output, `${JSON.stringify({ wall_ms: tick, rss_kib: row.rss_kib })}\n`);
await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval - (Date.now() - tick))));
}
return 0;
}
function seal(options) {
for (const key of ['report', 'rss', 'events']) if (!options[key]) { usage(); return 2; }
let report;
try { report = JSON.parse(fs.readFileSync(options.report, 'utf8')); }
catch { console.error(`PLAYABLE MEMORY: FAIL report unreadable ${path.basename(options.report)}`); return 1; }
const read = (file) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '');
const { report: sealed, code } = sealMemory(report, {
eventsText: read(options.events), rssText: read(options.rss),
warmupRounds: Number(options.warmup_rounds ?? 1), minRounds: Number(options.min_rounds ?? 10),
});
fs.writeFileSync(options.report, `${JSON.stringify(sealed, null, 2)}\n`);
const v = sealed.memory.verdict;
const line = `PLAYABLE MEMORY: ${v.status} ${v.reason} (first5=${v.first5_median_kib} last5=${v.last5_median_kib} KiB)`;
if (v.status === 'PASS') console.log(line); else console.error(line);
return code;
}
async function main() {
let options;
try { options = argsOf(process.argv.slice(2)); } catch (error) { usage(); console.error(error.message); return 2; }
if (options.help) { usage(process.stdout); return 0; }
if (options.sample_rss) return sampleRss(options);
if (options.seal) return seal(options);
usage();
return 2;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = await main();
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env node
/* STB-01 §9.1 memory gate regression. Synthetic RSS/rest-window data proves the
* leak rule (5 consecutive growing low waters AND last-5 median above
* max(50MiB, 5%) of the first-5 median), that missing data is BLOCKED rather
* than PASS, and that the external sampler records KiB for one PID only. */
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { parseRss, restWindows, roundLowWaters, leakVerdict, median, sealMemory } from './playable_soak_metrics.mjs';
const script = path.join(path.dirname(new URL(import.meta.url).pathname), 'playable_soak_metrics.mjs');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-soak-metrics.'));
let failures = 0;
const check = (ok, label) => { if (ok) console.log(`ok - ${label}`); else { failures += 1; console.error(`not ok - ${label}`); } };
const MIB = 1024;
const event = (round, state, wallMs, us) => JSON.stringify({ monotonic_us: us, run_id: 'r', case_id: 'STB-MEMORY-01', connection_epoch: 1,
stage: 'REST', kind: 'soak_rest', actor_vid: 0, target_vid: 0, payload: { round, state, wall_ms: wallMs } });
/** Build rounds of rest windows (30 s each, 1 Hz samples) whose low water follows `lows` (KiB). */
function fixture(lows, { restMs = 30000, gapMs = 60000, dropSamplesInRound = -1 } = {}) {
const events = [];
const rss = [];
let t = 1_700_000_000_000;
lows.forEach((low, round) => {
for (let s = 1; s < gapMs / 1000; s += 1) rss.push({ wall_ms: t + s * 1000, rss_kib: low + 80 * MIB });
t += gapMs;
events.push(event(round, 'start', t, round * 10 + 1));
for (let s = 0; s <= restMs / 1000; s += 1) {
if (round !== dropSamplesInRound) rss.push({ wall_ms: t + s * 1000, rss_kib: low + (s === 20 ? 0 : 3 * MIB) });
}
t += restMs;
events.push(event(round, 'end', t, round * 10 + 2));
});
return { eventsText: `${events.join('\n')}\n`, rssText: `${rss.map((r) => JSON.stringify(r)).join('\n')}\n` };
}
const flat = Array.from({ length: 12 }, (_, i) => 900 * MIB + (i % 2) * MIB);
const leaking = Array.from({ length: 12 }, (_, i) => 900 * MIB + (i < 6 ? 0 : (i - 5) * 20 * MIB));
const sawtooth = Array.from({ length: 12 }, (_, i) => 900 * MIB + i * 30 * MIB - (i % 3 === 0 ? 40 * MIB : 0));
const smallCreep = Array.from({ length: 12 }, (_, i) => 900 * MIB + i * 2 * MIB);
check(median([3, 1, 2]) === 2 && median([4, 1, 3, 2]) === 2.5, 'median of odd and even lists');
{
const rows = parseRss('{"wall_ms":1,"rss_kib":10}\nnot json\n{"wall_ms":2,"rss_kib":null}\n');
check(rows.samples.length === 2 && rows.bad_lines === 1 && rows.samples[1].rss_kib === null, 'rss parser keeps null samples and counts bad lines');
}
{
const { eventsText, rssText } = fixture(flat);
const windows = restWindows(eventsText);
check(windows.windows.length === 12 && windows.windows[0].round === 0 && windows.windows[0].rest_ms === 30000, 'rest windows come from soak_rest start/end events');
const lows = roundLowWaters(windows.windows, parseRss(rssText).samples);
check(lows[3].low_water_kib === flat[3] && lows[3].samples === 31, 'low water is the minimum RSS inside the rest window');
const verdict = leakVerdict(lows, { warmupRounds: 1 });
check(verdict.status === 'PASS' && verdict.rounds_analysed === 11, `flat memory passes (${verdict.status} ${verdict.reason})`);
check(/不能证明长期无泄漏/.test(verdict.note), 'a pass still says it cannot prove no long-term leak');
check(verdict.unit === 'KiB' && Number.isInteger(verdict.first5_median_kib) && Number.isInteger(verdict.last5_median_kib), 'first/last 5 medians are recorded in KiB');
}
{
const { eventsText, rssText } = fixture(leaking);
const verdict = leakVerdict(roundLowWaters(restWindows(eventsText).windows, parseRss(rssText).samples), { warmupRounds: 1 });
check(verdict.status === 'FAIL' && verdict.max_growth_streak >= 5 && verdict.growth_kib > verdict.threshold_kib, `steady growth fails (${JSON.stringify(verdict)})`);
check(verdict.threshold_kib === 50 * MIB, 'threshold is max(50MiB, 5%) — 50MiB for a 900MiB baseline');
}
{
// Large growth without 5 consecutive rising rounds is reported but does not fail the gate.
const verdict = leakVerdict(sawtooth.map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 });
check(verdict.status === 'PASS' && verdict.max_growth_streak < 5 && verdict.growth_kib > verdict.threshold_kib && verdict.warnings.length === 1,
`median growth without a 5-round streak is a warning (${JSON.stringify(verdict)})`);
}
{
const verdict = leakVerdict(smallCreep.map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 });
check(verdict.status === 'PASS' && verdict.max_growth_streak >= 5 && verdict.growth_kib <= verdict.threshold_kib, 'a monotonic creep below the threshold passes with the note');
}
{
const big = Array.from({ length: 12 }, (_, i) => 4096 * MIB + i * 60 * MIB);
const verdict = leakVerdict(big.map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 });
check(verdict.threshold_kib > 50 * MIB && verdict.threshold_kib === Math.ceil(0.05 * verdict.first5_median_kib), '5% wins for a large baseline');
}
{
const verdict = leakVerdict(flat.slice(0, 10).map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 });
check(verdict.status === 'BLOCKED' && /10/.test(verdict.reason), 'fewer than 10 rounds after warm-up is BLOCKED');
}
{
const { eventsText, rssText } = fixture(flat, { dropSamplesInRound: 4 });
const verdict = leakVerdict(roundLowWaters(restWindows(eventsText).windows, parseRss(rssText).samples), { warmupRounds: 1 });
check(verdict.status === 'BLOCKED' && /round 4/.test(verdict.reason), `a rest window without RSS samples is BLOCKED (${verdict.reason})`);
}
{
const { eventsText, rssText } = fixture(flat, { restMs: 10000 });
const verdict = leakVerdict(roundLowWaters(restWindows(eventsText).windows, parseRss(rssText).samples), { warmupRounds: 1 });
check(verdict.status === 'FAIL' && /30/.test(verdict.reason), 'a rest window shorter than 30 s violates the contract');
}
{
const unmatched = `${event(0, 'start', 1000, 1)}\n${event(1, 'start', 2000, 2)}\n`;
const windows = restWindows(unmatched);
check(windows.windows.length === 0 && windows.errors.length === 2, 'unmatched rest events are errors, not windows');
}
// Seal: the memory verdict downgrades the sealed report and never upgrades it.
{
const { eventsText, rssText } = fixture(leaking);
const base = { schema_version: 1, run_id: 'r', suite: 'soak', status: 'PASS', exit_gate: { checked: true, process_code: 0, timed_out: false, errors: [] } };
const sealed = sealMemory(structuredClone(base), { eventsText, rssText, warmupRounds: 1 });
check(sealed.report.status === 'FAIL' && sealed.code === 1, 'leak downgrades PASS to FAIL');
check(sealed.report.runner_cases?.[0]?.id === 'STB-MEMORY-01' && sealed.report.runner_cases[0].status === 'FAIL', 'runner case STB-MEMORY-01 is recorded');
check(sealed.report.memory.gpu_memory_kib === null && sealed.report.memory.gpu_memory_status === 'unavailable', 'GPU memory stays null/unavailable');
check(sealed.report.exit_gate.errors.some((e) => e.startsWith('STB-MEMORY-01')), 'memory verdict reaches exit_gate.errors');
const blocked = sealMemory(structuredClone(base), { eventsText: '', rssText, warmupRounds: 1 });
check(blocked.report.status === 'BLOCKED' && blocked.code === 2, 'no rest windows downgrades PASS to BLOCKED');
const failed = sealMemory({ ...structuredClone(base), status: 'FAIL' }, { ...fixture(flat), warmupRounds: 1 });
check(failed.report.status === 'FAIL' && failed.code === 1 && failed.report.runner_cases[0].status === 'PASS', 'a clean memory verdict never upgrades a FAIL');
}
// CLI seal writes in place; unknown input is a usage error.
{
const { eventsText, rssText } = fixture(flat);
const dir = path.join(root, 'seal');
fs.mkdirSync(dir);
fs.writeFileSync(path.join(dir, 'events.jsonl'), eventsText);
fs.writeFileSync(path.join(dir, 'rss.jsonl'), rssText);
fs.writeFileSync(path.join(dir, 'report.json'), JSON.stringify({ schema_version: 1, run_id: 'r', suite: 'soak', status: 'PASS', exit_gate: { checked: true, errors: [] } }));
const run = spawnSync(process.execPath, [script, '--seal', '--report', path.join(dir, 'report.json'), '--rss', path.join(dir, 'rss.jsonl'),
'--events', path.join(dir, 'events.jsonl')], { encoding: 'utf8' });
const out = JSON.parse(fs.readFileSync(path.join(dir, 'report.json'), 'utf8'));
check(run.status === 0 && out.status === 'PASS' && out.memory.rounds.length === 12, `CLI seal PASS (${run.status} ${run.stderr})`);
const bad = spawnSync(process.execPath, [script, '--seal', '--report', path.join(dir, 'report.json')], { encoding: 'utf8' });
check(bad.status === 2, 'missing seal arguments exit 2');
const help = spawnSync(process.execPath, [script, '--help'], { encoding: 'utf8' });
check(help.status === 0 && /--sample-rss/.test(help.stdout + help.stderr), '--help documents the sampler');
}
// Sampler: 1 Hz ps samples of one PID, KiB integers, stops by itself when that PID exits.
{
const target = spawn('/bin/sleep', ['3'], { stdio: 'ignore' });
const output = path.join(root, 'sampler.jsonl');
const started = Date.now();
const sampler = spawnSync(process.execPath, [script, '--sample-rss', '--pid', String(target.pid), '--output', output, '--interval-ms', '500'],
{ encoding: 'utf8', timeout: 15000 });
const rows = parseRss(fs.readFileSync(output, 'utf8')).samples;
check(sampler.status === 0 && Date.now() - started < 12000, `sampler exits after the PID is gone (${sampler.status} ${sampler.stderr})`);
check(rows.length >= 3 && rows.every((r) => Number.isInteger(r.rss_kib) && r.rss_kib > 0 && Number.isInteger(r.wall_ms)), `sampler writes KiB rows (${rows.length})`);
const refused = spawnSync(process.execPath, [script, '--sample-rss', '--pid', '0', '--output', output], { encoding: 'utf8' });
check(refused.status === 2, 'sampler refuses a non-positive PID');
}
fs.rmSync(root, { recursive: true, force: true });
console.log(failures === 0 ? 'PASS: playable_soak_metrics_test' : `FAIL: playable_soak_metrics_test (${failures})`);
process.exitCode = failures === 0 ? 0 : 1;
+101 -27
View File
@@ -1,56 +1,130 @@
#!/usr/bin/env bash
# Public entry for the first playable client gate. It delegates process
# ownership and post-exit validation to run_client_gate.sh.
# Public entry for the first playable client gate (INF-02 / REL-01).
#
# Runs N independent client processes through run_client_gate.sh into
# ROOT/run-<n>, writes ROOT/release-manifest.json listing exactly those runs and
# the candidate package hashes, then aggregates ONLY the listed runs. Historical
# PASS reports elsewhere are never scanned. Credentials are asked once (hidden
# TTY prompt) or taken from MT_ACCOUNT/MT_PASSWORD; never from arguments/files.
# Do not add `set -x` to this script.
#
# exit: 0 PASS, 1 FAIL, 2 BLOCKED, 124 a run hit the wall-clock timeout.
set -euo pipefail
set +x
cd "$(dirname "$0")/.."
app="${MT_PLAYABLE_APP:-$PWD/build/export-native-trees-20260911/mtgodot-poc.app}"
repo="$PWD"
app="${MT_PLAYABLE_APP:-}"
config=""
output=""
mode="playable"
timeout="180"
timeout="900"
allow=0
repeat=1
suite="full"
assets=""
serverlist="${MT_PLAYABLE_SERVERLIST:-}"
usage() {
cat <<'EOF'
用法: script/playable_test.sh --config CONFIG [选项]
--app APP 导出包路径
--output DIR 单次运行输出目录(repeat=1
--suite full 当前固定为 full/playable
--allow-gameplay 允许发送游戏状态变更请求
--repeat N 独立进程运行次数,默认 1
--timeout-seconds N 每次墙钟超时,默认 180
用法: script/playable_test.sh --app APP --config CONFIG [选项]
--app APP 导出包路径(候选包;所有 run 必须是同一哈希)
--output DIR 本批次根目录,必须不存在或为空(默认 build/playable/suite-<时间>
--suite playable|full 必测用例集合,默认 full
--repeat N 独立进程运行次数,默认 1(发布要求见 docs/FIRST-MAC-PLAYABLE-IMPLEMENTATION.md
--timeout-seconds N 每次墙钟超时,默认 900
--assets DIR 资源预检根目录(透传 run_client_gate.sh
--serverlist FILE 测试模式 serverlist 覆盖(透传)
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取/施法请求
退出码: 0 PASS / 1 FAIL / 2 BLOCKED / 124 超时
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--app) app="$2"; shift 2 ;;
--config) config="$2"; shift 2 ;;
--output) output="$2"; shift 2 ;;
--suite) suite="$2"; shift 2 ;;
--app) app="${2:-}"; shift 2 ;;
--config) config="${2:-}"; shift 2 ;;
--output) output="${2:-}"; shift 2 ;;
--suite) suite="${2:-}"; shift 2 ;;
--repeat) repeat="${2:-}"; shift 2 ;;
--timeout-seconds) timeout="${2:-}"; shift 2 ;;
--assets) assets="${2:-}"; shift 2 ;;
--serverlist) serverlist="${2:-}"; shift 2 ;;
--allow-gameplay) allow=1; shift ;;
--repeat) repeat="$2"; shift 2 ;;
--timeout-seconds) timeout="$2"; shift 2 ;;
--help) usage; exit 0 ;;
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [ -z "$config" ] || [ ! -f "$config" ]; then echo "需要 --config CONFIG" >&2; exit 2; fi
if [ -z "$app" ] || [ ! -d "$app" ]; then echo "PLAYABLE SUITE: BLOCKED 需要 --app 指向候选导出包" >&2; exit 2; fi
if [ -z "$config" ] || [ ! -f "$config" ]; then echo "PLAYABLE SUITE: BLOCKED 需要 --config CONFIG" >&2; exit 2; fi
if [ "$suite" != "full" ] && [ "$suite" != "playable" ]; then echo "--suite 只支持 full/playable" >&2; exit 2; fi
if ! [[ "$repeat" =~ ^[1-9][0-9]*$ ]]; then echo "--repeat 必须是正整数" >&2; exit 2; fi
if [ "$repeat" -gt 1 ] && [ -n "$output" ]; then echo "repeat>1 时不能指定单一 --output" >&2; exit 2; fi
root="$output"
if [ -z "$root" ]; then root="${MT_PLAYABLE_OUTPUT:-$PWD/build/playable/suite-$(date +%Y%m%d-%H%M%S)-$$}"; fi
if [ -z "$root" ]; then root="$repo/build/playable/suite-$(date +%Y%m%d-%H%M%S)-$$"; fi
if [ -e "$root" ] && [ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
echo "PLAYABLE SUITE: BLOCKED 输出目录已有内容,拒绝混入历史运行:$root" >&2
exit 2
fi
mkdir -p "$root"
failed=0
root="$(cd "$root" && pwd)"
# Ask once for the whole batch; child runners inherit the environment only.
if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then
if [ -t 0 ]; then
if [ -z "${MT_ACCOUNT:-}" ]; then read -r -s -p "测试账号: " MT_ACCOUNT; echo >&2; fi
if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "测试密码: " MT_PASSWORD; echo >&2; fi
fi
fi
if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then
echo "PLAYABLE SUITE: BLOCKED 缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端" >&2
exit 2
fi
export MT_ACCOUNT MT_PASSWORD
# The candidate identity is computed once, before any run, from the same package.
bash script/playable_build_info.sh "$app" >"$root/candidate.json"
runs_json="[]"
timed_out=0
for index in $(seq 1 "$repeat"); do
run_dir="$root"
if [ "$repeat" -gt 1 ]; then run_dir="$root/run-$index"; fi
args=(--app "$app" --config "$config" --output "$run_dir" --mode "$mode" --timeout-seconds "$timeout")
run_dir="$root/run-$index"
args=(--app "$app" --config "$config" --output "$run_dir" --suite "$suite" --mode "$mode" --timeout-seconds "$timeout")
if [ -n "$assets" ]; then args+=(--assets "$assets"); fi
if [ -n "$serverlist" ]; then args+=(--serverlist "$serverlist"); fi
if [ "$allow" -eq 1 ]; then args+=(--allow-gameplay); fi
if ! bash script/run_client_gate.sh "${args[@]}"; then failed=$((failed + 1)); fi
set +e
bash script/run_client_gate.sh "${args[@]}" </dev/null
code=$?
set -e
if [ "$code" -eq 124 ]; then timed_out=1; fi
# A run that never produced a sealed report is still listed so the release fails loudly.
runs_json="$(node -e '
const fs = require("node:fs");
const [runs, dir, name, suite] = process.argv.slice(1);
let runId = `${name}-unsealed`;
try { runId = JSON.parse(fs.readFileSync(`${dir}/report.json`, "utf8")).run_id || runId; } catch {}
const list = JSON.parse(runs);
list.push({ run_id: runId, suite, path: name });
process.stdout.write(JSON.stringify(list));
' "$runs_json" "$run_dir" "run-$index" "$suite")"
done
if [ "$failed" -ne 0 ]; then echo "PLAYABLE SUITE: FAIL runs=$failed/$repeat" >&2; exit 1; fi
echo "PLAYABLE SUITE: PASS runs=$repeat root=$root"
node -e '
const fs = require("node:fs");
const [root, suite, repeat, runs] = process.argv.slice(1);
const candidate = JSON.parse(fs.readFileSync(`${root}/candidate.json`, "utf8"));
fs.writeFileSync(`${root}/release-manifest.json`, JSON.stringify({
schema_version: 1, candidate, required_runs: { [suite]: Number(repeat) }, runs: JSON.parse(runs),
}, null, 2) + "\n");
' "$root" "$suite" "$repeat" "$runs_json"
set +e
node script/validate_playable_report.mjs --release-dir "$root"
release_code=$?
set -e
if [ "$timed_out" -eq 1 ]; then echo "PLAYABLE SUITE: TIMEOUT root=$root" >&2; exit 124; fi
case "$release_code" in
0) echo "PLAYABLE SUITE: PASS runs=$repeat root=$root" ;;
2) echo "PLAYABLE SUITE: BLOCKED root=$root" >&2 ;;
*) echo "PLAYABLE SUITE: FAIL root=$root" >&2; release_code=1 ;;
esac
exit "$release_code"
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env node
/* Copy stdin to stdout line by line, replacing the test credentials before any
* byte reaches disk. Secrets come only from MT_ACCOUNT / MT_PASSWORD in the
* environment and are never printed, logged or passed as arguments.
*
* node script/redact_stream.mjs <fifo >client.log
*
* Uses blocking reads on fd 0: async stdin on a macOS FIFO does not reliably
* report EOF, which would leave the runner waiting for the redactor. */
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
const MIN_LENGTH = 2;
export function secretLiterals(env = process.env) {
return ['MT_PASSWORD', 'MT_ACCOUNT']
.map((name) => env[name] || '')
.filter((value) => value.length >= MIN_LENGTH)
// Longest first so an account contained in a password is not half-replaced.
.sort((a, b) => b.length - a.length);
}
export function redact(line, literals = secretLiterals()) {
let out = line;
for (const literal of literals) out = out.split(literal).join('[redacted]');
return out;
}
function pump() {
const literals = secretLiterals();
const buffer = Buffer.alloc(64 * 1024);
let pending = '';
const write = (text) => {
const bytes = Buffer.from(text);
let offset = 0;
while (offset < bytes.length) offset += fs.writeSync(1, bytes, offset);
};
for (;;) {
let count;
try {
count = fs.readSync(0, buffer, 0, buffer.length, null);
} catch (error) {
if (error.code === 'EAGAIN') continue;
if (error.code === 'EOF') break;
throw error;
}
if (count === 0) break;
pending += buffer.toString('utf8', 0, count);
const lines = pending.split('\n');
pending = lines.pop();
if (lines.length > 0) write(lines.map((line) => `${redact(line, literals)}\n`).join(''));
}
// A trailing partial line is still redacted as a whole.
if (pending !== '') write(`${redact(pending, literals)}\n`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1])) {
if (process.argv.includes('--help')) {
console.log('usage: node script/redact_stream.mjs < INPUT > OUTPUT (reads MT_ACCOUNT/MT_PASSWORD from env)');
} else {
pump();
}
}
+3 -3
View File
@@ -11,15 +11,15 @@ run_test() {
if [ "$mode" = headless ]; then options+=(--headless); fi
"${GODOT:-godot}" "${options[@]}" --script "$test_script" --quit-after 1800 >"$log" 2>&1
local status=$?
if [ "$status" -ne 0 ] || rg -q 'SCRIPT ERROR|Parse Error|^FAIL:|check\(s\) failed' "$log" || ! rg -q 'PASS|failures=0' "$log"; then
if [ "$status" -ne 0 ] || grep -qE 'SCRIPT ERROR|Parse Error|^FAIL:|check\(s\) failed' "$log" || ! grep -qE 'PASS|failures=0' "$log"; then
echo "FAIL $mode $test_script (exit=$status): $log"
failed=$((failed + 1))
else
echo "PASS $mode $test_script"
fi
if rg -q 'leaked at exit' "$log"; then echo "WARN resource leak: $log"; fi
if grep -q 'leaked at exit' "$log"; then echo "WARN resource leak: $log"; fi
}
for test_script in animation_cache_test.gd target_effect_test.gd effect_space_test.gd effect_faces_test.gd motion_effect_anchor_test.gd mob_winding_test.gd water_reference_test.gd effect_texture_animation_test.gd dds_lifecycle_test.gd effect_color_operation_test.gd effect_color_test.gd effect_scale_test.gd effect_rotation_test.gd effect_surface_test.gd effect_emission_test.gd effect_render_regression_test.gd effect_playback_test.gd fx_test.gd skill_fx_test.gd player_skill_test.gd skill_test.gd damage_effect_test.gd combat_fx_test.gd app_flow_lifecycle_test.gd playable_harness_test.gd playable_adapter_test.gd gamescene_test.gd equip_model_test.gd equip_rules_test.gd race_motion_assembly_test.gd character_winding_test.gd; do
for test_script in animation_cache_test.gd target_effect_test.gd effect_space_test.gd effect_faces_test.gd motion_effect_anchor_test.gd mob_winding_test.gd water_reference_test.gd effect_texture_animation_test.gd dds_lifecycle_test.gd effect_color_operation_test.gd effect_color_test.gd effect_scale_test.gd effect_rotation_test.gd effect_surface_test.gd effect_emission_test.gd effect_render_regression_test.gd effect_playback_test.gd fx_test.gd skill_fx_test.gd player_skill_test.gd skill_test.gd damage_effect_test.gd combat_fx_test.gd app_flow_lifecycle_test.gd playable_harness_test.gd playable_adapter_test.gd playable_combat_test.gd gamescene_test.gd equip_model_test.gd equip_rules_test.gd race_motion_assembly_test.gd character_winding_test.gd mob_view_test.gd net_world_vis_test.gd forest_viewpoints_test.gd playable_metrics_test.gd; do
run_test headless "$test_script"
done
for test_script in effect_faces_test.gd effect_lie_test.gd effect_texture_animation_test.gd effect_color_operation_test.gd effect_surface_test.gd effect_emission_test.gd effect_rotation_test.gd model_render_test.gd gpu_lod_attachment_test.gd gpu_pose_bounds_test.gd forest_mob_render_test.gd; do
+250 -67
View File
@@ -1,125 +1,308 @@
#!/usr/bin/env bash
# Run one exported client process and seal its report after exit.
# This script owns exactly one child PID; it never pkills other clients.
# INF-02: run ONE exported client process and seal its report after it exits.
#
# - Owns exactly one child PID (plus its own log redactor); never pkills other
# clients, never touches routes/firewall/system caches.
# - Credentials only from MT_ACCOUNT/MT_PASSWORD or a hidden TTY prompt; never
# from arguments or files. Do not add `set -x` to this script.
# - The client writes client-report.json; this script writes the final
# report.json only after the real process exit and the full-log gate.
# - suite soak (STB-01): also samples RSS of the child PID once per second into
# rss.jsonl, seals STB-MEMORY-01 into report.json (can only downgrade), and when
# soak.faults is confirmed runs script/playable_fault_proxy.mjs on 127.0.0.1 for
# this client's connection only (for every suite, since server.* then points at the
# proxy). Sampler and proxy are this script's own children.
#
# exit: 0 PASS, 1 assert/exit-gate FAIL, 2 config/precondition BLOCKED, 124 wall-clock timeout.
set -euo pipefail
set +x
cd "$(dirname "$0")/.."
app_path="${MT_PLAYABLE_APP:-$PWD/build/export-native-trees-20260911/mtgodot-poc.app}"
repo="$PWD"
app_path="${MT_PLAYABLE_APP:-}"
mode="playable"
suite="playable"
config_path=""
output_dir="${MT_PLAYABLE_OUTPUT:-$PWD/build/playable/run-$(date +%Y%m%d-%H%M%S)-$$}"
timeout_seconds="${MT_PLAYABLE_TIMEOUT_SECONDS:-180}"
assets_root=""
serverlist="${MT_PLAYABLE_SERVERLIST:-}"
output_dir=""
timeout_seconds="${MT_PLAYABLE_TIMEOUT_SECONDS:-900}"
allow_gameplay=0
required_arch="${MT_PLAYABLE_REQUIRED_ARCH:-arm64}"
godot_bin="${MT_GODOT:-godot}"
kill_grace_seconds=10
usage() {
cat <<'EOF'
用法: script/run_client_gate.sh --app APP --config CONFIG [选项]
选项:
--output DIR 本次运行目录,必须不存在或为空
--mode MODE 默认 playable
--timeout-seconds N 墙钟超时,默认 180
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取请求
--output DIR 本次运行目录,必须不存在或为空(默认 build/playable/run-<时间>-<pid>
--suite playable|full|soak 必测用例集合,默认 playablesoak 需 --timeout-seconds >= soak.duration_seconds+900
--mode MODE 包内 MT_TEST_MODE,默认 playable
--assets DIR 资源预检根目录(默认包内 Contents/Resources/assets
--serverlist FILE 测试模式 serverlist 覆盖;预检与客户端使用同一文件
--timeout-seconds N 墙钟超时,默认 900;超时 TERM10 秒后 KILL
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取/施法请求
环境变量:
MT_ACCOUNT / MT_PASSWORD 测试账号(未设置且 stdin 是终端时隐藏输入;否则返回 2)
MT_PLAYABLE_REQUIRED_ARCH 包必须包含的架构,默认 arm64
退出码: 0 PASS / 1 FAIL / 2 BLOCKED(配置或前置条件)/ 124 墙钟超时
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--app) app_path="$2"; shift 2 ;;
--config) config_path="$2"; shift 2 ;;
--output) output_dir="$2"; shift 2 ;;
--mode) mode="$2"; shift 2 ;;
--timeout-seconds) timeout_seconds="$2"; shift 2 ;;
--app) app_path="${2:-}"; shift 2 ;;
--config) config_path="${2:-}"; shift 2 ;;
--output) output_dir="${2:-}"; shift 2 ;;
--suite) suite="${2:-}"; shift 2 ;;
--mode) mode="${2:-}"; shift 2 ;;
--assets) assets_root="${2:-}"; shift 2 ;;
--serverlist) serverlist="${2:-}"; shift 2 ;;
--timeout-seconds) timeout_seconds="${2:-}"; shift 2 ;;
--allow-gameplay) allow_gameplay=1; shift ;;
--help) usage; exit 0 ;;
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [ -z "$config_path" ] || [ ! -f "$config_path" ]; then
echo "配置不存在;不会启动客户端:$config_path" >&2
output_ready=0
blocked() {
echo "PLAYABLE GATE: BLOCKED $*" >&2
if [ "$output_ready" -eq 1 ]; then echo "BLOCKED $*" >>"$output_dir/gate.log"; fi
exit 2
}
[ -n "$app_path" ] || blocked "需要 --app"
[ -n "$config_path" ] && [ -f "$config_path" ] || blocked "配置不存在;不会启动客户端"
[ "$suite" = "playable" ] || [ "$suite" = "full" ] || [ "$suite" = "soak" ] || blocked "--suite 只支持 playable/full/soak"
[[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || blocked "--timeout-seconds 必须是正整数"
config_path="$(cd "$(dirname "$config_path")" && pwd)/$(basename "$config_path")"
engine="$app_path/Contents/MacOS/mtgodot-poc"
[ -x "$engine" ] || blocked "找不到可执行包:$app_path"
if [ -n "$serverlist" ]; then
[ -f "$serverlist" ] || blocked "serverlist 不存在"
serverlist="$(cd "$(dirname "$serverlist")" && pwd)/$(basename "$serverlist")"
fi
if [ ! -x "$app_path/Contents/MacOS/mtgodot-poc" ]; then
echo "找不到可执行包:$app_path" >&2
exit 2
if [ -z "$output_dir" ]; then
output_dir="$repo/build/playable/run-$(date +%Y%m%d-%H%M%S)-$$"
fi
if ! [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then
echo "timeout-seconds 必须是正整数" >&2
exit 2
fi
if [ -e "$output_dir" ] && [ "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
echo "输出目录已有内容,拒绝复用历史报告:$output_dir" >&2
exit 2
if [ -e "$output_dir" ] && [ -n "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
blocked "输出目录已有内容,拒绝复用历史报告:$output_dir"
fi
mkdir -p "$output_dir"
output_dir="$(cd "$output_dir" && pwd)"
output_ready=1
# 2. package identity: signature and architecture before anything runs.
if ! codesign --verify --strict "$app_path" >/dev/null 2>&1; then
blocked "签名校验失败:codesign --verify --strict $app_path"
fi
archs="$(lipo -archs "$engine" 2>/dev/null || true)"
case " $archs " in
*" $required_arch "*) ;;
*) blocked "包架构 [$archs] 不含 $required_arch" ;;
esac
run_id="$(basename "$output_dir")-$(od -An -N4 -tx4 /dev/urandom | tr -d ' ')"
log="$output_dir/client.log"
client_report="$output_dir/client-report.json"
final_report="$output_dir/report.json"
events="$output_dir/events.jsonl"
if ! MT_PLAYABLE_VALIDATE_CONFIG="$config_path" godot --headless --path project --script playable_config_gate.gd >"$output_dir/config.log" 2>&1; then
echo "配置校验失败;不会启动客户端:$output_dir/config.log" >&2
exit 2
if [ -z "$assets_root" ]; then
if [ -d "$app_path/Contents/Resources/assets" ]; then
assets_root="$app_path/Contents/Resources/assets"
else
assets_root="$repo/assets"
fi
fi
char_slot="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const d=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(String(d.character_slot));' "$config_path")"
sha256_of() {
if [ -f "$1" ]; then shasum -a 256 "$1" | awk '{print $1}'; else echo ""; fi
# 3. config + address + resource preconditions; resolves the address the client will use.
if ! env MT_PLAYABLE_VALIDATE_CONFIG="$config_path" MT_PLAYABLE_SUITE="$suite" \
MT_PLAYABLE_ASSETS="$assets_root" MT_PLAYABLE_SERVERLIST="$serverlist" \
MT_PLAYABLE_GATE_OUTPUT="$output_dir" \
"$godot_bin" --headless --path project --script playable_config_gate.gd >"$output_dir/config.log" 2>&1; then
blocked "配置/资源预检失败;不会启动客户端:$output_dir/config.log"
fi
# The config gate validated any soak block (for every suite); read only the fields the runner needs.
read -r soak_duration soak_warmup soak_min_rounds fault_status up_auth_host up_auth_port up_game_host up_game_port <<<"$(node -e '
const c = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
const s = c.soak || {}; const f = s.faults || {}; const u = f.upstream || {};
process.stdout.write([s.duration_seconds || 0, s.warmup_rounds ?? 1, s.min_rounds || 10, f.status || "unconfirmed", u.auth_host || "-", u.auth_port || 0, u.game_host || "-", u.game_port || 0].join(" "));
' "$config_path")"
if [ "$suite" = "soak" ]; then
[ "$timeout_seconds" -ge $((soak_duration + 900)) ] || blocked "soak 需要 --timeout-seconds >= soak.duration_seconds + 900(当前 ${timeout_seconds}"
fi
# Credentials: env or hidden prompt only.
if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then
if [ -t 0 ]; then
if [ -z "${MT_ACCOUNT:-}" ]; then read -r -s -p "测试账号: " MT_ACCOUNT; echo >&2; fi
if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "测试密码: " MT_PASSWORD; echo >&2; fi
fi
fi
[ -n "${MT_ACCOUNT:-}" ] && [ -n "${MT_PASSWORD:-}" ] || blocked "缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端"
export MT_ACCOUNT MT_PASSWORD
read_address() {
node -e '
const a = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
process.stdout.write([a.auth_host, a.auth_port, a.game_host, a.game_port].join(" "));
' "$output_dir/server-address.json"
}
engine="$app_path/Contents/MacOS/mtgodot-poc"
pck="$app_path/Contents/Resources/mtgodot-poc.pck"
dylib="$(find "$app_path/Contents/Frameworks" -maxdepth 1 -type f -name '*.dylib' -print -quit)"
read -r auth_host auth_port game_host game_port <<<"$(read_address)"
proxy_pid=""
stop_proxy() {
if [ -n "$proxy_pid" ]; then
kill -TERM "$proxy_pid" 2>/dev/null || true
wait "$proxy_pid" 2>/dev/null || true
proxy_pid=""
fi
}
trap stop_proxy EXIT
if [ "$fault_status" = "confirmed" ]; then
# Loopback proxy in front of the test server for this client only. server.* points at it, so
# playable exit runs sharing the scenario go through it too; only the soak client requests faults.
for endpoint in "$up_auth_host:$up_auth_port" "$up_game_host:$up_game_port"; do
if ! nc -z -G 3 "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then
blocked "故障代理上游不可达:$endpoint"
fi
done
routes=(--route "$auth_port=$up_auth_host:$up_auth_port")
if [ "$game_port" != "$auth_port" ]; then
routes+=(--route "$game_port=$up_game_host:$up_game_port")
elif [ "$up_game_host:$up_game_port" != "$up_auth_host:$up_auth_port" ]; then
blocked "auth/game 共用代理端口,但上游地址不同"
fi
node script/playable_fault_proxy.mjs "${routes[@]}" --events "$events" --log "$output_dir/fault-proxy.jsonl" \
--run-id "$run_id" >"$output_dir/fault-proxy.log" 2>&1 </dev/null &
proxy_pid=$!
proxy_wait=0
until grep -q '"action":"listening"' "$output_dir/fault-proxy.jsonl" 2>/dev/null; do
if ! kill -0 "$proxy_pid" 2>/dev/null || [ "$proxy_wait" -ge 50 ]; then
blocked "故障代理未能在 127.0.0.1 监听:$output_dir/fault-proxy.log"
fi
sleep 0.1
proxy_wait=$((proxy_wait + 1))
done
echo "FAULT PROXY pid=$proxy_pid listen=127.0.0.1:$auth_port,$game_port" >>"$output_dir/gate.log"
fi
for endpoint in "$auth_host:$auth_port" "$game_host:$game_port"; do
if ! nc -z -G 3 "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then
blocked "服务器端口不可达:$endpoint"
fi
done
char_slot="$(node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")).character_slot))' "$config_path")"
# 1. identity of this run.
bash script/playable_build_info.sh "$app_path" >"$output_dir/build.json"
build_field() { node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"))[process.argv[2]]))' "$output_dir/build.json" "$1"; }
export MT_TEST_MODE="$mode"
export MT_PLAYABLE_SUITE="$suite"
export MT_PLAYABLE_CONFIG="$config_path"
export MT_PLAYABLE_RUN_ID="$run_id"
export MT_PLAYABLE_SERVERLIST="$serverlist"
export MT_TEST_REPORT="$client_report"
export MT_TEST_EVENTS="$events"
export MT_TEST_OUTPUT="$output_dir"
export MT_PROTOCOL="classic"
export MT_AUTOLOGIN=1
export MT_CHAR_SLOT="$char_slot"
export MT_PLAYABLE_RUN_ID="$(basename "$output_dir")"
export MT_BUILD_ENGINE_SHA256="$(sha256_of "$engine")"
export MT_BUILD_EXTENSION_SHA256="$(sha256_of "$dylib")"
export MT_BUILD_PCK_SHA256="$(sha256_of "$pck")"
file_description="$(file "$engine")"
case "$file_description" in
*arm64*) export MT_BUILD_ARCH="arm64" ;;
*x86_64*) export MT_BUILD_ARCH="x86_64" ;;
*) export MT_BUILD_ARCH="unknown" ;;
esac
if [ "$allow_gameplay" -eq 1 ]; then export MT_PLAYABLE_ALLOW_GAMEPLAY=1; else export MT_PLAYABLE_ALLOW_GAMEPLAY=0; fi
export MT_BUILD_ENGINE_SHA256="$(build_field engine_sha256)"
export MT_BUILD_EXTENSION_SHA256="$(build_field extension_sha256)"
export MT_BUILD_PCK_SHA256="$(build_field pck_sha256)"
export MT_BUILD_ARCH="$(build_field arch)"
export MT_PLAYABLE_ALLOW_GAMEPLAY="$allow_gameplay"
start_epoch="$(date +%s)"
# 4/5. child stdout+stderr -> FIFO -> redactor -> client.log. The child PID is
# the engine itself (redirections are applied before exec), so `wait` returns
# its real status regardless of the log pipe.
fifo="$output_dir/.client.fifo"
mkfifo "$fifo"
started_ms="$(($(date +%s) * 1000))"
set +e
env -u MT_ASSETS "$engine" >"$log" 2>&1 &
node script/redact_stream.mjs <"$fifo" >"$log" &
redactor_pid=$!
env -u MT_ASSETS "$engine" >"$fifo" 2>&1 </dev/null &
child_pid=$!
sampler_pid=""
if [ "$suite" = "soak" ]; then
node script/playable_soak_metrics.mjs --sample-rss --pid "$child_pid" --output "$output_dir/rss.jsonl" \
</dev/null >>"$output_dir/gate.log" 2>&1 &
sampler_pid=$!
fi
SECONDS=0
timed_out=0
while kill -0 "$child_pid" 2>/dev/null; do
now="$(date +%s)"
if [ $((now - start_epoch)) -ge "$timeout_seconds" ]; then
if [ "$SECONDS" -ge "$timeout_seconds" ]; then
timed_out=1
kill -TERM "$child_pid" 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "$child_pid" 2>/dev/null || break
kill -TERM "$child_pid" 2>/dev/null
grace=0
while kill -0 "$child_pid" 2>/dev/null && [ "$grace" -lt "$kill_grace_seconds" ]; do
sleep 1
grace=$((grace + 1))
done
kill -KILL "$child_pid" 2>/dev/null || true
killed=0
if kill -0 "$child_pid" 2>/dev/null; then kill -KILL "$child_pid" 2>/dev/null; killed=1; fi
echo "TIMEOUT term_grace_s=$grace killed=$killed" >>"$output_dir/gate.log"
break
fi
sleep 1
done
wait "$child_pid"
app_status=$?
set -e
if [ "$timed_out" -eq 1 ]; then app_status=124; fi
set +e
node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \
--log "$log" --process-code "$app_status" --require-pass
gate_status=$?
set -e
if [ "$app_status" -ne 0 ] || [ "$gate_status" -ne 0 ]; then
echo "PLAYABLE GATE: FAIL exit=$app_status report=$final_report log=$log" >&2
exit 1
process_code=$?
# The redactor ends at EOF; bound the wait in case a grandchild kept the FIFO open.
redactor_wait=0
while kill -0 "$redactor_pid" 2>/dev/null && [ "$redactor_wait" -lt 10 ]; do
sleep 1
redactor_wait=$((redactor_wait + 1))
done
if kill -0 "$redactor_pid" 2>/dev/null; then
kill -TERM "$redactor_pid" 2>/dev/null
echo "PLAYABLE GATE: log redactor did not reach EOF (FIFO held open)" >>"$output_dir/gate.log"
fi
echo "PLAYABLE GATE: PASS report=$final_report log=$log"
wait "$redactor_pid"
redactor_code=$?
rm -f "$fifo"
if [ -n "$sampler_pid" ]; then
# The sampler stops by itself once the child is gone; bound the wait anyway.
sampler_wait=0
while kill -0 "$sampler_pid" 2>/dev/null && [ "$sampler_wait" -lt 10 ]; do
sleep 1
sampler_wait=$((sampler_wait + 1))
done
if kill -0 "$sampler_pid" 2>/dev/null; then kill -TERM "$sampler_pid" 2>/dev/null; fi
wait "$sampler_pid"
fi
stop_proxy
node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \
--log "$log" --process-code "$process_code" --timed-out "$timed_out" --run-id "$run_id" \
--suite "$suite" --required-cases "$output_dir/required-cases.json" --build "$output_dir/build.json" \
--events "$events" --started-ms "$started_ms" --redactor-code "$redactor_code" --require-pass
gate_status=$?
if [ "$suite" = "soak" ]; then
node script/playable_soak_metrics.mjs --seal --report "$final_report" --rss "$output_dir/rss.jsonl" --events "$events" \
--warmup-rounds "$soak_warmup" --min-rounds "$soak_min_rounds"
memory_status=$?
if [ "$gate_status" -ne 0 ] && [ "$gate_status" -ne 2 ]; then gate_status=1
elif [ "$memory_status" -ne 0 ] && [ "$memory_status" -ne 2 ]; then gate_status=1
elif [ "$gate_status" -eq 2 ] || [ "$memory_status" -eq 2 ]; then gate_status=2
fi
fi
set -e
if [ "$timed_out" -eq 1 ]; then
echo "PLAYABLE GATE: TIMEOUT raw_exit=$process_code report=$final_report log=$log" >&2
exit 124
fi
case "$gate_status" in
0) echo "PLAYABLE GATE: PASS run_id=$run_id report=$final_report" ;;
2) echo "PLAYABLE GATE: BLOCKED raw_exit=$process_code report=$final_report" >&2 ;;
*) echo "PLAYABLE GATE: FAIL raw_exit=$process_code report=$final_report log=$log" >&2; gate_status=1 ;;
esac
exit "$gate_status"
+268 -62
View File
@@ -1,15 +1,37 @@
#!/usr/bin/env node
/* Validate the machine-readable playable report and, when requested, seal it
* with the parent process' exit/log gate. The client cannot mark a run PASS by
* itself: the final report is written only after the child has exited. */
/* INF-02 / REL-01 report gate.
*
* Run mode (called by run_client_gate.sh after the child exited):
* --report client-report.json --output report.json --log client.log
* --process-code N --timed-out 0|1 --run-id ID --suite S
* --required-cases required-cases.json --build build.json --events events.jsonl
* --started-ms MS [--redactor-code N] [--require-pass]
* Seals the final report. The client can never mark a run PASS by itself.
*
* Release mode:
* --release-dir DIR reads DIR/release-manifest.json, checks every explicitly
* listed sealed run (same candidate hashes, PASS, counts), writes
* DIR/release-report.json. History is never scanned.
*
* exit: 0 PASS, 1 FAIL, 2 BLOCKED (only BLOCKED/SKIP findings, clean process+log).
* Credential literals are read from MT_ACCOUNT/MT_PASSWORD and never printed. */
import fs from 'node:fs';
import path from 'node:path';
const VALID = new Set(['PASS', 'FAIL', 'BLOCKED', 'SKIP']);
const ERROR_RE = /SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed/m;
const SUITES = new Set(['playable', 'full', 'soak', 'forest_render']);
// Same rule as script/package_render_test.sh, plus parse errors. No RID allow-list.
const ERROR_RE = /SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed/;
const EVENT_KEYS = ['monotonic_us', 'run_id', 'case_id', 'connection_epoch', 'stage', 'kind', 'actor_vid', 'target_vid', 'payload'];
const HASH_KEYS = ['engine_sha256', 'extension_sha256', 'pck_sha256'];
const HEX64 = /^[0-9a-f]{64}$/;
const SECRET_MIN_SCAN = 4;
const MTIME_SLACK_MS = 1000;
function usage() {
console.error('usage: validate_playable_report.mjs --report PATH [--output PATH] [--log PATH] [--process-code N] [--run-id ID] [--require-pass]');
console.error('usage: validate_playable_report.mjs --report PATH --output PATH --log PATH --process-code N --run-id ID '
+ '--suite S --required-cases PATH --build PATH --events PATH --started-ms MS [--timed-out 0|1] [--require-pass]\n'
+ ' validate_playable_report.mjs --release-dir DIR');
}
function argsOf(argv) {
@@ -17,97 +39,281 @@ function argsOf(argv) {
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--require-pass') out.require_pass = true;
else if (arg === '--help') out.help = true;
else if (arg.startsWith('--')) {
const key = arg.slice(2).replaceAll('-', '_');
if (i + 1 >= argv.length) throw new Error(`missing value for ${arg}`);
out[key] = argv[++i];
} else throw new Error(`unknown argument: ${arg}`);
}
if (!out.report) throw new Error('--report is required');
if (out.help) return out;
if (!out.release_dir) {
for (const key of ['report', 'output', 'log', 'process_code', 'run_id', 'suite', 'required_cases', 'build', 'events', 'started_ms']) {
if (out[key] === undefined) throw new Error(`--${key.replaceAll('_', '-')} is required`);
}
}
return out;
}
function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
catch (error) { return { __read_error: `${file}: ${error.message}` }; }
catch (error) { return { __read_error: `${path.basename(file)}: ${error.code || 'invalid JSON'}` }; }
}
function validate(report, options) {
const errors = [];
const readError = report?.__read_error;
if (readError) errors.push(readError);
function secretLiterals() {
return ['MT_ACCOUNT', 'MT_PASSWORD'].map((name) => process.env[name] || '').filter((v) => v.length >= SECRET_MIN_SCAN);
}
function scanSecrets(label, text, errors) {
for (const literal of secretLiterals()) {
if (text.includes(literal)) errors.fail.push(`${label} contains a credential literal`);
}
}
class Findings {
constructor() { this.fail = []; this.blocked = []; }
get all() { return [...new Set([...this.fail, ...this.blocked])]; }
}
function checkBuild(build, expected, prefix, errors) {
for (const key of HASH_KEYS) {
if (typeof build?.[key] !== 'string' || !HEX64.test(build[key])) errors.fail.push(`${prefix}build.${key} is missing or not sha256`);
else if (expected && build[key] !== expected[key]) errors.fail.push(`${prefix}build.${key} differs from the candidate`);
}
if (typeof build?.arch !== 'string' || build.arch.trim() === '') errors.fail.push(`${prefix}build.arch is missing`);
else if (expected && build.arch !== expected.arch) errors.fail.push(`${prefix}build.arch differs from the candidate`);
}
function validateReport(report, options, errors) {
if (report.__read_error) {
errors.fail.push(`client report unreadable: ${report.__read_error}`);
return;
}
if (!report || typeof report !== 'object' || Array.isArray(report)) {
errors.push('report root must be an object');
return errors;
errors.fail.push('report root must be an object');
return;
}
if (report.schema_version !== 1) errors.push('schema_version must be 1');
if (typeof report.run_id !== 'string' || report.run_id.trim() === '') errors.push('run_id is missing');
if (options.run_id && report.run_id !== options.run_id) errors.push('run_id does not match requested run');
if (report.suite !== 'playable') errors.push('suite must be playable');
if (!VALID.has(report.status)) errors.push(`invalid report status: ${report.status}`);
for (const key of ['engine_sha256', 'extension_sha256', 'pck_sha256', 'arch']) {
if (typeof report.build?.[key] !== 'string' || report.build[key].trim() === '') errors.push(`build.${key} is missing`);
}
if (!Array.isArray(report.cases) || report.cases.length === 0) errors.push('cases must be non-empty');
if (report.schema_version !== 1) errors.fail.push('schema_version must be 1');
if (typeof report.run_id !== 'string' || report.run_id.trim() === '') errors.fail.push('run_id is missing');
else if (report.run_id !== options.run_id) errors.fail.push('run_id does not match this run (stale or foreign report)');
if (report.suite !== options.suite) errors.fail.push(`suite ${report.suite} does not match ${options.suite}`);
if (!VALID.has(report.status)) errors.fail.push(`invalid report status: ${report.status}`);
const expectedBuild = readJson(options.build);
if (expectedBuild.__read_error) errors.fail.push(`build.json unreadable: ${expectedBuild.__read_error}`);
checkBuild(report.build, expectedBuild.__read_error ? null : expectedBuild, '', errors);
const required = readJson(options.required_cases);
const requiredIds = Array.isArray(required?.cases) ? required.cases : null;
if (!requiredIds || requiredIds.length === 0) errors.fail.push('required-cases.json is missing or empty');
else if (required.suite !== options.suite) errors.fail.push('required-cases.json suite mismatch');
const cases = Array.isArray(report.cases) ? report.cases : [];
if (cases.length === 0) errors.fail.push('cases must be non-empty');
const seen = new Map();
let passed = 0;
for (const item of cases) {
if (!item || typeof item !== 'object' || typeof item.id !== 'string' || item.id.trim() === '') {
errors.push('case id is missing');
errors.fail.push('case id is missing');
continue;
}
if (!VALID.has(item.status)) errors.push(`${item.id}: invalid status`);
if (seen.has(item.id)) errors.fail.push(`${item.id}: duplicated case`);
seen.set(item.id, item);
if (!VALID.has(item.status)) errors.fail.push(`${item.id}: invalid status`);
if (item.status === 'PASS') passed += 1;
if (item.status === 'BLOCKED' || item.status === 'SKIP') errors.push(`${item.id}: ${item.status} is not releasable`);
if (!Array.isArray(item.evidence)) errors.push(`${item.id}: evidence must be an array`);
if (item.status === 'FAIL') errors.fail.push(`${item.id}: FAIL ${item.reason || ''}`.trim());
if (item.status === 'BLOCKED' || item.status === 'SKIP') errors.blocked.push(`${item.id}: ${item.status} is not releasable (${item.reason || 'no reason'})`);
if (!Array.isArray(item.evidence)) errors.fail.push(`${item.id}: evidence must be an array`);
for (const evidence of (Array.isArray(item.evidence) ? item.evidence : [])) {
if (typeof evidence !== 'string' || evidence.trim() === '') errors.push(`${item.id}: empty evidence path`);
else if (!fs.existsSync(path.resolve(path.dirname(options.report), evidence))) errors.push(`${item.id}: missing evidence ${evidence}`);
const resolved = typeof evidence === 'string' ? path.resolve(path.dirname(options.report), evidence) : '';
if (!resolved || !resolved.startsWith(path.resolve(path.dirname(options.report)) + path.sep)) errors.fail.push(`${item.id}: evidence path escapes the run directory`);
else if (!fs.existsSync(resolved)) errors.fail.push(`${item.id}: missing evidence ${evidence}`);
}
}
if (requiredIds) {
for (const id of requiredIds) if (!seen.has(id)) errors.fail.push(`${id}: required case missing from report`);
for (const id of seen.keys()) if (!requiredIds.includes(id)) errors.fail.push(`${id}: case is not in the required list`);
if (JSON.stringify(report.required_cases) !== JSON.stringify(requiredIds)) errors.fail.push('report required_cases differs from the runner list');
}
const coverage = report.coverage;
if (!coverage || coverage.required !== cases.length || coverage.passed !== passed) errors.push('coverage does not match cases');
if (!Array.isArray(report.failures) || !Array.isArray(report.blocked)) errors.push('failures and blocked must be arrays');
if (!report.exit_gate || report.exit_gate.checked !== true) errors.push('exit_gate.checked must be true in final report');
if (options.process_code !== undefined && Number(report.exit_gate?.process_code) !== Number(options.process_code)) errors.push('exit_gate.process_code does not match child process');
if (Number(report.exit_gate?.process_code) !== 0) errors.push('child process did not exit with code 0');
if (options.log) {
if (!fs.existsSync(options.log)) errors.push(`log does not exist: ${options.log}`);
else {
const lines = fs.readFileSync(options.log, 'utf8').split(/\r?\n/);
lines.forEach((line, index) => { if (ERROR_RE.test(line)) errors.push(`log:${index + 1}: ${line}`); });
if (!coverage || coverage.required !== cases.length || coverage.passed !== passed
|| (requiredIds && coverage.required !== requiredIds.length)) errors.fail.push('coverage does not match cases');
if (!Array.isArray(report.failures) || !Array.isArray(report.blocked)) errors.fail.push('failures and blocked must be arrays');
else if (report.failures.length > 0) errors.fail.push('client report contains failures');
if (report.status === 'PASS' && (passed !== cases.length || report.blocked?.length)) errors.fail.push('client PASS contradicts its cases');
// Staleness: the client report must have been written during this run.
try {
const mtime = fs.statSync(options.report).mtimeMs;
if (mtime + MTIME_SLACK_MS < Number(options.started_ms)) errors.fail.push('client report predates this run (stale)');
} catch { /* unreadable already reported */ }
// Event contract.
let lines = [];
if (fs.existsSync(options.events)) {
const text = fs.readFileSync(options.events, 'utf8');
scanSecrets('events', text, errors);
lines = text.split('\n').filter((line) => line.trim() !== '');
}
if (report.status === 'PASS' && lines.length === 0) errors.fail.push('PASS report has no events');
if (Number(report.event_count ?? 0) !== lines.length) errors.fail.push(`event_count ${report.event_count} does not match events lines ${lines.length}`);
let badEvents = 0;
let lastUs = -Infinity;
for (const line of lines) {
let event;
try { event = JSON.parse(line); } catch { badEvents += 1; continue; }
if (!EVENT_KEYS.every((key) => Object.hasOwn(event, key)) || event.run_id !== options.run_id
|| typeof event.payload !== 'object' || event.payload === null || Array.isArray(event.payload)
|| !Number.isInteger(event.connection_epoch) || event.monotonic_us < lastUs) badEvents += 1;
else lastUs = event.monotonic_us;
}
if (badEvents > 0) errors.fail.push(`${badEvents} events violate the contract (keys/run_id/order)`);
}
function validateProcess(options, errors) {
const code = Number(options.process_code);
if (options.timed_out === '1') errors.fail.push(`wall-clock timeout (raw exit ${code})`);
if (code !== 0) errors.fail.push(`child process exit code ${code}`);
if (options.redactor_code !== undefined && Number(options.redactor_code) !== 0) errors.fail.push('log redactor did not finish cleanly');
if (!fs.existsSync(options.log)) {
errors.fail.push('client log does not exist');
return;
}
const text = fs.readFileSync(options.log, 'utf8');
scanSecrets('client log', text, errors);
const hits = text.split(/\r?\n/).map((line, index) => [line, index]).filter(([line]) => ERROR_RE.test(line));
// Report the line numbers and the matched rule, not arbitrary log text.
for (const [line, index] of hits.slice(0, 50)) errors.fail.push(`log:${index + 1}: ${line.match(ERROR_RE)[0]}`);
if (hits.length > 50) errors.fail.push(`log: ${hits.length - 50} more error lines`);
}
function sealRun(options) {
const errors = new Findings();
const input = readJson(options.report);
validateReport(input, options, errors);
validateProcess(options, errors);
const report = input.__read_error ? {
schema_version: 1, run_id: options.run_id, suite: options.suite, status: 'FAIL',
build: readJson(options.build), cases: [], failures: [input.__read_error], blocked: [],
coverage: { required: 0, passed: 0 },
} : input;
let status = 'PASS';
if (errors.fail.length > 0) status = 'FAIL';
else if (errors.blocked.length > 0) status = 'BLOCKED';
if (options.require_pass && status === 'PASS' && report.status !== 'PASS') { status = 'FAIL'; errors.fail.push(`client status ${report.status}`); }
report.status = status;
report.exit_gate = {
checked: true,
process_code: Number(options.process_code),
timed_out: options.timed_out === '1',
errors: errors.all,
};
const text = `${JSON.stringify(report, null, 2)}\n`;
const leak = new Findings();
scanSecrets('final report', text, leak);
const output = options.output;
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
if (leak.fail.length > 0) {
// Never persist a report that would carry the secret; keep only the verdict.
fs.writeFileSync(output, `${JSON.stringify({ schema_version: 1, run_id: options.run_id, suite: options.suite, status: 'FAIL',
exit_gate: { checked: true, process_code: Number(options.process_code), timed_out: options.timed_out === '1', errors: leak.fail } }, null, 2)}\n`);
console.error('PLAYABLE REPORT: FAIL (credential literal in report)');
return 1;
}
fs.writeFileSync(output, text);
if (status !== 'PASS') {
console.error(`PLAYABLE REPORT: ${status} (${errors.all.length} findings)`);
errors.all.slice(0, 40).forEach((error) => console.error(`- ${error}`));
return status === 'BLOCKED' ? 2 : 1;
}
console.log(`PLAYABLE REPORT: PASS ${output}`);
return 0;
}
function sealRelease(dir) {
const errors = new Findings();
const root = path.resolve(dir);
const manifest = readJson(path.join(root, 'release-manifest.json'));
const summary = { schema_version: 1, status: 'FAIL', candidate: null, runs: [], counts: {}, errors: [] };
if (manifest.__read_error) errors.fail.push(`release-manifest.json unreadable: ${manifest.__read_error}`);
else {
if (manifest.schema_version !== 1) errors.fail.push('manifest schema_version must be 1');
checkBuild(manifest.candidate, null, 'manifest.', errors);
summary.candidate = manifest.candidate;
const runs = Array.isArray(manifest.runs) ? manifest.runs : [];
if (runs.length === 0) errors.fail.push('manifest lists no runs');
const ids = new Set();
const passedBySuite = {};
const blockedBySuite = {};
for (const run of runs) {
const label = `run ${run?.run_id ?? '?'}`;
if (typeof run?.run_id !== 'string' || typeof run?.path !== 'string' || !SUITES.has(run?.suite)) {
errors.fail.push(`${label}: run_id/path/suite missing`);
continue;
}
if (ids.has(run.run_id)) errors.fail.push(`${label}: listed twice`);
ids.add(run.run_id);
const reportPath = path.resolve(root, run.path, 'report.json');
if (!reportPath.startsWith(root + path.sep)) {
errors.fail.push(`${label}: path escapes the release directory`);
continue;
}
const report = readJson(reportPath);
const entry = { run_id: run.run_id, suite: run.suite, path: run.path, status: report.status ?? 'MISSING' };
summary.runs.push(entry);
if (report.__read_error) {
// run_client_gate.sh records precondition stops in gate.log before any client starts.
const gateLog = path.resolve(root, run.path, 'gate.log');
const preconditionBlocked = fs.existsSync(gateLog) && /^BLOCKED /m.test(fs.readFileSync(gateLog, 'utf8'));
if (preconditionBlocked) {
entry.status = 'BLOCKED';
blockedBySuite[run.suite] = (blockedBySuite[run.suite] || 0) + 1;
errors.blocked.push(`${label}: precondition BLOCKED before launch`);
} else errors.fail.push(`${label}: sealed report missing`);
continue;
}
if (report.run_id !== run.run_id) errors.fail.push(`${label}: report run_id mismatch`);
if (report.suite !== run.suite) errors.fail.push(`${label}: report suite mismatch`);
if (report.exit_gate?.checked !== true || report.exit_gate?.process_code !== 0 || report.exit_gate?.timed_out !== false) errors.fail.push(`${label}: report not sealed by a clean exit gate`);
checkBuild(report.build, manifest.candidate, `${label}: `, errors);
if (report.status === 'PASS') passedBySuite[run.suite] = (passedBySuite[run.suite] || 0) + 1;
else if (report.status === 'BLOCKED') {
blockedBySuite[run.suite] = (blockedBySuite[run.suite] || 0) + 1;
errors.blocked.push(`${label}: BLOCKED`);
}
else errors.fail.push(`${label}: ${report.status}`);
}
const requiredRuns = manifest.required_runs && typeof manifest.required_runs === 'object' ? manifest.required_runs : {};
if (Object.keys(requiredRuns).length === 0) errors.fail.push('manifest required_runs is empty');
for (const [suite, count] of Object.entries(requiredRuns)) {
const got = passedBySuite[suite] || 0;
summary.counts[suite] = { required: count, passed: got };
if (!Number.isInteger(count) || count < 1) errors.fail.push(`required_runs.${suite} must be a positive integer`);
else if (got < count) {
// Missing passes explained by BLOCKED runs stay BLOCKED; anything else is a FAIL.
const message = `${suite}: ${got}/${count} passing runs`;
if (got + (blockedBySuite[suite] || 0) >= count) errors.blocked.push(message);
else errors.fail.push(message);
}
}
}
if (options.require_pass && report.status !== 'PASS') errors.push(`report status is ${report.status}, expected PASS`);
if (report.status === 'FAIL') errors.push('client report already contains failures');
return [...new Set(errors)];
summary.status = errors.fail.length ? 'FAIL' : (errors.blocked.length ? 'BLOCKED' : 'PASS');
summary.errors = errors.all;
fs.writeFileSync(path.join(root, 'release-report.json'), `${JSON.stringify(summary, null, 2)}\n`);
if (summary.status !== 'PASS') {
console.error(`PLAYABLE RELEASE: ${summary.status}`);
summary.errors.forEach((error) => console.error(`- ${error}`));
return summary.status === 'BLOCKED' ? 2 : 1;
}
console.log(`PLAYABLE RELEASE: PASS ${path.join(root, 'release-report.json')}`);
return 0;
}
function main() {
let options;
try { options = argsOf(process.argv.slice(2)); }
catch (error) { usage(); console.error(error.message); return 2; }
const input = readJson(options.report);
const report = input.__read_error ? {
schema_version: 1, run_id: options.run_id || 'missing-report', suite: 'playable', status: 'FAIL',
build: { engine_sha256: '', extension_sha256: '', pck_sha256: '', arch: '' }, cases: [],
failures: [input.__read_error], blocked: [], coverage: { required: 0, passed: 0 },
} : input;
const exitCode = options.process_code === undefined ? null : Number(options.process_code);
report.exit_gate = { checked: true, process_code: exitCode, errors: [] };
const errors = validate(report, options);
report.exit_gate.errors = errors;
if (errors.length > 0) report.status = 'FAIL';
const output = options.output || options.report;
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
if (errors.length) {
console.error(`PLAYABLE REPORT: FAIL (${errors.length} errors)`);
errors.forEach((error) => console.error(`- ${error}`));
return 1;
}
console.log(`PLAYABLE REPORT: PASS ${output}`);
return 0;
if (options.help) { usage(); return 0; }
if (options.release_dir) return sealRelease(options.release_dir);
return sealRun(options);
}
process.exitCode = main();