Files
mtgodot-poc/script/audit_playable_maps.mjs
T
shenleiandClaude Opus 5 f39a55fdd5 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
2026-09-11 22:06:48 +09:00

446 lines
23 KiB
JavaScript

#!/usr/bin/env node
/* 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: 'build/playable/map-assets.json', maps: '', config: '', races: '' };
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
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 (out.config && out.maps) throw new Blocked('use either --config or --maps, not both');
return out;
}
const posix = value => value.replaceAll('\\', '/');
const read = file => fs.readFileSync(file, 'latin1');
const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
// 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();
}
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 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 = [];
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 || !/^\d+$/.test(lines[1])) continue;
objects.push({ crc: Number(lines[1]), position: lines[0] });
}
return objects;
}
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 entries;
}
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);
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;
}
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;
}
}