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;
}
}