#!/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 / 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 /textureset/. 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; } }