#!/usr/bin/env node // Converts a wowdecomp tree from address tags to stable symbol IDs. // // node migrate.js --dump --tree --out // // 1. Builds one symbol per function across builds, joined through the address maps: // 15890 x64 <-map_15595_15890- 15595 x64 <-map64_15595- 15595 x86 // and one per data symbol (joined by decorated name), then assigns IDs: // fn_ / gl_ / vt_ / st_, ordered by 15890 address, then 15595 x64, then x86. // 2. Rewrites the tree in place: // // OFFSET: 0x14... (15890) -> // FUNCTION: fn_xxxxx // // OFFSET: #0x14... (15595 x64), %0x00... (15595 x86) -> the same, by map // // OFFSET: [a, b] -> one line per distinct ID, template args kept as detail // // STATIC: ... -> // GLOBAL: gl_xxxxx (or VTABLE/STRING by kind) // // PROVEN: ... -> removed (match status lives in the coordinator) // Tags that cannot be resolved become `// TODO(retag): ` and are listed in the report. // 3. Writes /symbols.json (for `td-server symbols load`) and /report.json. import fs from 'node:fs'; import path from 'node:path'; import { parseArgs } from 'node:util'; import { formatSymbolId } from '../../src/shared/annotations.js'; const { values: opt } = parseArgs({ options: { dump: { type: 'string' }, tree: { type: 'string' }, out: { type: 'string' } } }); for (const k of ['dump', 'tree', 'out']) if (!opt[k]) throw new Error(`--${k} is required`); fs.mkdirSync(opt.out, { recursive: true }); const V890 = '15890-x64'; const V595 = '15595-x64'; const V595X86 = '15595-x86'; const X64_MIN = 0x140000000; const load = (n) => JSON.parse(fs.readFileSync(path.join(opt.dump, n + '.json'), 'utf8')); const hexa = (n) => '0x' + n.toString(16).toUpperCase(); // ---------------------------------------------------------------- functions const funcs = { [V890]: new Map(), [V595]: new Map(), [V595X86]: new Map() }; for (const [ea, size, name, flags] of load('func_15890_x64')) funcs[V890].set(ea, { size, name, flags }); for (const [ea, size, name, flags] of load('func_15595_x64')) funcs[V595].set(ea, { size, name, flags }); for (const [ea, size, name, flags] of load('func_15595_x86')) funcs[V595X86].set(ea, { size, name, flags }); const status890 = new Map(load('status15890').map(([ea, size, st, mod, file]) => [ea, { st, mod, file }])); const to890 = new Map(); const from890 = new Map(); for (const [a, b] of load('map_15595_15890')) if (funcs[V595].has(a) && funcs[V890].has(b)) (to890.set(a, b), from890.set(b, a)); const x86to64 = new Map(); const x64to86 = new Map(); for (const [a, b] of load('map64_15595')) if (funcs[V595X86].has(a) && funcs[V595].has(b) && !x64to86.has(b)) (x86to64.set(a, b), x64to86.set(b, a)); const decorated890 = new Map(); for (const [ea, sym, verdict] of load('byte_match15890')) if (verdict === 'IDENTICAL' && sym) decorated890.set(ea, sym); const isNamed = (n) => n && !/^(sub|nullsub|j|unknown_libname)_[0-9A-Fa-f]+/.test(n); const entities = []; const fnAt = { [V890]: new Map(), [V595]: new Map(), [V595X86]: new Map() }; function addFunc(addrs) { const e = { kind: 'function', addrs, name: null, decorated: null, library: false, unit: null }; for (const [v, a] of Object.entries(addrs)) if (a != null) fnAt[v].set(a, e); entities.push(e); return e; } for (const ea of [...funcs[V890].keys()].sort((a, b) => a - b)) { const e595 = from890.get(ea) ?? null; const e = addFunc({ [V890]: ea, [V595]: e595, [V595X86]: e595 != null ? x64to86.get(e595) ?? null : null }); const st = status890.get(ea); e.library = st?.st === 'library'; e.unit = st?.file ? st.file.replace(/\\/g, '/') : null; e.decorated = decorated890.get(ea) ?? null; const n = funcs[V890].get(ea).name; if (isNamed(n)) e.name = n; } for (const ea of [...funcs[V595].keys()].sort((a, b) => a - b)) { if (fnAt[V595].has(ea)) continue; const e = addFunc({ [V890]: null, [V595]: ea, [V595X86]: x64to86.get(ea) ?? null }); const n = funcs[V595].get(ea).name; if (isNamed(n)) e.name = n; } for (const ea of [...funcs[V595X86].keys()].sort((a, b) => a - b)) { if (fnAt[V595X86].has(ea)) continue; const e = addFunc({ [V890]: null, [V595]: null, [V595X86]: ea }); const n = funcs[V595X86].get(ea).name; if (isNamed(n)) e.name = n; } // ---------------------------------------------------------------- data const dataAt = { [V890]: new Map(), [V595]: new Map(), [V595X86]: new Map() }; const dataKind = (sym) => (sym?.startsWith('??_7') ? 'vtable' : sym?.startsWith('??_C@') ? 'string' : 'global'); function addData(addrs, decorated = null, kind = dataKind(decorated)) { const e = { kind, addrs, name: null, decorated, library: false, unit: null }; for (const [v, a] of Object.entries(addrs)) if (a != null) dataAt[v].set(a, e); entities.push(e); return e; } const data595 = new Map(load('data_15595_x64').map(([s, p, ea]) => [s, ea])); const joined = new Set(); for (const [sym, plain, ea] of load('data_15890_x64').sort((a, b) => a[2] - b[2])) { if (dataAt[V890].has(ea)) continue; const old = data595.get(sym); const e = addData({ [V890]: ea, [V595]: old != null && !dataAt[V595].has(old) ? old : null }, sym); e.name = plain; if (old != null) joined.add(sym); } for (const [sym, plain, ea] of load('data_15595_x64').sort((a, b) => a[2] - b[2])) { if (joined.has(sym) || dataAt[V595].has(ea)) continue; addData({ [V595]: ea }, sym).name = plain; } // ---------------------------------------------------------------- tags const report = { files: 0, changedFiles: 0, tags: 0, functionTags: 0, staticTags: 0, provenRemoved: 0, unresolved: [], duplicates: [], conflicts: [] }; const SRC_EXT = /\.(c|cc|cpp|cxx|h|hpp|inl)$/i; const TAG_RE = /^(\s*)\/\/\s*(OFFSET|STATIC):\s*(.+?)\s*$/; const PROVEN_RE = /^\s*\/\/\s*PROVEN:/; const TOKEN_RE = /^([%#]?)0x([0-9A-Fa-f]+)$/; /** Splits `[a, b, c]` at top-level commas. */ function splitList(body) { const inner = body.replace(/^\[/, '').replace(/\]$/, ''); const out = []; let depth = 0; let cur = ''; for (const ch of inner) { if (ch === '<') depth++; if (ch === '>') depth--; if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; } else cur += ch; } if (cur.trim()) out.push(cur.trim()); return out; } function versionOf(prefix, addr) { if (prefix === '#') return V595; if (prefix === '%') return V595X86; return addr >= X64_MIN ? V890 : null; } /** Resolves one entry like `#0x1405E5B10` or `%0x00F49520/0x000E3D630`. */ function resolveEntry(entry, isStatic, where) { const t = /^([^<]+?)\s*(<.*>)?$/.exec(entry); const detail = t?.[2] ?? null; const parts = (t?.[1] ?? entry).split('/'); const found = []; let bare = false; for (const p of parts) { const m = TOKEN_RE.exec(p.trim()); if (!m) continue; const addr = parseInt(m[2], 16); const v = versionOf(m[1], addr); if (!v) continue; let e = (isStatic ? dataAt : fnAt)[v].get(addr); if (!e && isStatic) e = addData({ [v]: addr }); if (e) found.push(e); if (v === V890) bare = true; } const uniq = [...new Set(found)]; if (uniq.length > 1) report.conflicts.push({ ...where, entry, ids: uniq.length }); return uniq.length ? { e: uniq[0], detail, bare } : null; } /** Qualified name of the function defined on a line like `int CFoo::Bar(int x) {` (no overload info). */ function definedName(line) { const m = /([~\w]+(?:<[^()]*>)?(?:::[~\w]+(?:<[^()]*>)?)*|operator\s*[^\s(]+)\s*\(/.exec(line ?? ''); return m ? m[1].replace(/<[^()]*>/g, '') : null; } function nextCodeLine(lines, i) { for (let j = i + 1; j < lines.length; j++) { const s = lines[j].trim(); if (!s || s.startsWith('//')) continue; return s; } return null; } const fileTags = []; // [{file, line, e, detail, kind}] function walk(dir, rel, out) { for (const d of fs.readdirSync(dir, { withFileTypes: true })) { if (d.name.startsWith('.') || (!rel && ['bin', 'out', 'build'].includes(d.name))) continue; const r = rel ? `${rel}/${d.name}` : d.name; if (d.isDirectory()) walk(path.join(dir, d.name), r, out); else if (SRC_EXT.test(d.name)) out.push(r); } } const files = []; walk(opt.tree, '', files); const rewritten = new Map(); for (const file of files) { report.files++; const abs = path.join(opt.tree, file); const text = fs.readFileSync(abs, 'latin1'); if (!/\/\/\s*(OFFSET|STATIC|PROVEN):/.test(text)) continue; const eol = text.includes('\r\n') ? '\r\n' : '\n'; const lines = text.split(/\r?\n/); const out = []; let changed = false; lines.forEach((line, i) => { if (PROVEN_RE.test(line)) { report.provenRemoved++; changed = true; return; } const m = TAG_RE.exec(line); if (!m) return void out.push(line); const [, indent, kind, body] = m; const isStatic = kind === 'STATIC'; report.tags++; isStatic ? report.staticTags++ : report.functionTags++; const entries = body.startsWith('[') ? splitList(body) : [body]; const where = { file, line: i + 1 }; const seen = new Set(); const resolved = []; for (const entry of entries) { const r = resolveEntry(entry, isStatic, where); if (!r) { report.unresolved.push({ ...where, entry, tag: line.trim() }); continue; } if (seen.has(r.e)) continue; seen.add(r.e); resolved.push(r); } changed = true; if (!resolved.length) return void out.push(`${indent}// TODO(retag): ${line.trim().replace(/^\/\/\s*/, '')}`); const defName = isStatic ? null : definedName(nextCodeLine(lines, i)); for (const r of resolved) { const placeholder = { kind: isStatic ? 'data' : 'function', e: r.e, bare: r.bare, defName, detail: entries.length > 1 ? r.detail : null, file, line: i + 1, indent, outIndex: out.length }; fileTags.push(placeholder); out.push(placeholder); } if (resolved.length < entries.length && entries.length > 1) { // Keep a trace of list entries that did not resolve. for (const u of report.unresolved.filter((x) => x.file === file && x.line === i + 1)) out.push(`${indent}// TODO(retag): unresolved list entry ${u.entry}`); } }); if (changed) rewritten.set(file, { out, eol }); } // ---------------------------------------------------------------- map sanity // A proven (bare 15890) tag owns its address. An old-address tag that the map sends to the same // 15890 function but sits on a differently named definition is a wrong map pair: the old function // keeps its 15595 addresses as a symbol of its own, without a 15890 address. const owners = new Map(); // entity -> Set of defined names with a bare tag for (const t of fileTags) if (t.kind === 'function' && t.bare && t.defName) owners.set(t.e, (owners.get(t.e) ?? new Set()).add(t.defName.split('::').pop())); const splits = new Map(); report.mapRejected = []; for (const t of fileTags) { if (t.kind !== 'function' || t.bare || !t.defName) continue; const names = owners.get(t.e); if (!names || names.has(t.defName.split('::').pop())) continue; // Several differently named owners, or a stub-sized function: the linker folds those, the pair can be right. const size890 = funcs[V890].get(t.e.addrs[V890])?.size ?? 0; if (names.size > 1 || size890 < 24) continue; if (!splits.has(t.e)) { const old = t.e; const e = { kind: 'function', addrs: { [V890]: null, [V595]: old.addrs[V595], [V595X86]: old.addrs[V595X86] }, name: null, decorated: null, library: false, unit: null }; old.addrs[V595] = null; old.addrs[V595X86] = null; entities.push(e); splits.set(old, e); report.mapRejected.push({ file: t.file, line: t.line, defined: t.defName, owner: [...names].join(','), ea890: hexa(old.addrs[V890]) }); } t.e = splits.get(t.e); } // ---------------------------------------------------------------- IDs const counters = { fn: 0, gl: 0, vt: 0, st: 0 }; const prefixOf = { function: 'fn', global: 'gl', vtable: 'vt', string: 'st' }; const sortKey = (e) => [e.addrs[V890], e.addrs[V595], e.addrs[V595X86]].map((a) => (a == null ? Infinity : a)); const ordered = entities.slice().sort((a, b) => { const ka = sortKey(a); const kb = sortKey(b); for (let i = 0; i < 3; i++) if (ka[i] !== kb[i]) return ka[i] - kb[i]; return 0; }); // Data entities created only from a tag that nothing else knows still need an ID; all others too. for (const e of ordered) e.id = formatSymbolId(prefixOf[e.kind], ++counters[prefixOf[e.kind]]); const annotationFor = { function: 'FUNCTION', global: 'GLOBAL', vtable: 'VTABLE', string: 'STRING' }; const firstUse = new Map(); for (const t of fileTags) { const key = t.e.id; if (firstUse.has(key) && t.kind === 'function') { const prev = firstUse.get(key); // Template instances and identical copies legitimately share a body; a second tag elsewhere is a duplicate. if (prev.file !== t.file || t.detail == null) report.duplicates.push({ id: key, first: prev.file, again: t.file }); } else firstUse.set(key, t); } for (const [file, { out, eol }] of rewritten) { const lines = out.map((x) => { if (typeof x === 'string') return x; const kw = annotationFor[x.e.kind]; return `${x.indent}// ${kw}: ${x.e.id}${x.detail ? ' ' + x.detail : ''}`; }); fs.writeFileSync(path.join(opt.tree, file), lines.join(eol), 'latin1'); report.changedFiles++; } // ---------------------------------------------------------------- outputs const used = new Set(fileTags.map((t) => t.e.id)); const symbols = ordered.map((e) => ({ id: e.id, kind: e.kind, name: e.name, decorated: e.decorated, library: e.library, unit: e.unit, addrs: Object.fromEntries(Object.entries(e.addrs).filter(([, a]) => a != null).map(([v, a]) => [v, [a, (e.kind === 'function' ? funcs[v].get(a)?.size : null) ?? null]])), })); // Functions already proven byte-identical against 15890 by verify15890.py, and tagged with that address. const proven = []; for (const t of fileTags) { if (t.kind !== 'function') continue; const ea = t.e.addrs[V890]; if (ea != null && decorated890.has(ea)) proven.push({ id: t.e.id, file: t.file }); } fs.writeFileSync(path.join(opt.out, 'symbols.json'), JSON.stringify({ versions: [V890, V595, V595X86], current: V890, symbols, proven })); const summary = { ...report, unresolved: report.unresolved.length, duplicates: report.duplicates.length, conflicts: report.conflicts.length, symbols: Object.fromEntries(Object.entries(counters).map(([k, n]) => [k, n])), taggedIds: used.size, proven: proven.length, maps: { to890: to890.size, x86to64: x86to64.size, rejected: report.mapRejected.length }, }; fs.writeFileSync(path.join(opt.out, "report.json"), JSON.stringify({ summary, unresolved: report.unresolved, duplicates: report.duplicates, conflicts: report.conflicts, mapRejected: report.mapRejected }, null, 1)); console.log(JSON.stringify(summary, null, 2));