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:
+410
-95
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user