'use strict'; const fs = require('fs'); const path = require('path'); const cheerio = require('cheerio'); const C = require('./constants'); const U = require('./utils'); // ─── Main expand function ─── function expand(input, output, opts) { opts = opts || {}; const { filedir, aptName, baseName } = U.resolveFilePaths(input); const tmpdir = U.createTempDir(); let success = false; try { // 1. Copy files to temp const { manifestData, datData } = U.copyInputToTemp(filedir, tmpdir, baseName, aptName); // 2. Run apt2xml.exe U.runApt2Xml(opts.toolsDir, tmpdir, baseName); // 3. Read raw XML const rawXml = U.readRawXml(tmpdir, baseName); // 4. Parse manifest if available (for texture name resolution) const manifest = manifestData ? cheerio.load(manifestData, { xmlMode: true, decodeEntities: false }) : null; // 5. Parse .dat if available const dats = parseDat(datData); // 6. Run xmladdhint() transformation const humanXml = xmladdhint(rawXml, baseName, aptName, manifest, dats, filedir, opts.oldStyle); // 7. Write output const outPath = output || (filedir + baseName + '_edit.xml'); fs.writeFileSync(outPath, humanXml, 'utf-8'); success = true; return outPath; } finally { if (!opts.keepTemp) { U.cleanupTempDir(tmpdir, false); } else { console.warn('Temporary files kept in: ' + tmpdir); } } } // ─── Parse .dat file ─── function parseDat(datData) { const dats = {}; if (!datData) return dats; const lines = datData.split(/[\r\n]+/); for (let i = 0; i < lines.length; i++) { let m; if ((m = lines[i].match(/([0-9]+)\-\>([0-9]+)/))) { dats[m[1]] = m[2]; } else if ((m = lines[i].match(/([0-9]+)\=([0-9\s]+)/))) { dats[m[1]] = m[2].split(/\s+/); } } return dats; } // ─── xmladdhint(): raw apt XML → human-readable XML ─── function xmladdhint(rawXml, baseName, aptName, manifest, dats, filedir, oldStyle) { U.resetLabels(); const $ = cheerio.load(rawXml, { xmlMode: true, decodeEntities: false }); const ActionNames = oldStyle ? C.ActionNames2 : C.ActionNames; const xmldoc = $; // Remove duplicated exports const exp = {}; $('export').each(function () { const nm = $(this).attr('name'); if (exp[nm]) { $(this).remove(); } else { exp[nm] = true; } }); // Calculate aligned positions for each action container const actionContainers = $('initaction, buttonaction, action, clipaction'); actionContainers.each(function () { const list = $(this).children('[action]'); U.calcaln(list); }); // Add end-of-function markers const functions = $(C.FUNCTION_SPLITTERS); functions.each(function () { const v = $(this); if (v[0].tagName.indexOf('definefunction') === 0) { const size = parseInt(v.attr('size'), 10); const headaln = U.thisaln(v.next()); let c = null; let sz = 0; let cur = v; for (; sz <= size && cur.length; ) { c = cur; sz = U.nextaln(c) - headaln; if (sz === size) { break; } cur = cur.next(); c = null; } if (c) { const endNode = U.el('end', $); endNode.insertAfter(c); endNode.attr('aln', U.nextaln(c)); U.copyindent(endNode, c); } } }); // Resolve constants (expand constant pool references) const actionConsts = $(C.CONST_ACTION_SELECTOR); actionConsts.each(function () { const v = $(this); const val = parseInt(v.attr('val'), 10); const c = v.parent().find('constantpool').children().eq(val); if (c.attr('str') !== undefined) { v.attr('str', c.attr('str')); } else { v.attr('num', c.attr('num')); } }); // Resolve jump targets const gotos = $('goto'); gotos.each(function () { const v = $(this); const action = parseInt(v.attr('action'), 10); if (action === C.ACTION_BRANCHALWAYS || action === C.EA_BRANCHIFFALSE || action === C.ACTION_BRANCHIFTRUE) { let pos = parseInt(v.attr('pos'), 10); let targetlabel = v.attr('targetlabel'); if (!targetlabel) { const naln = U.nextaln(v); const jumpaln = naln + pos; const filter = '[aln=' + jumpaln + ']:first'; const target = pos >= 0 ? v.nextAll(filter) : v.prevAll(filter); if (target.length) { let label = target.attr('mylabel'); if (!label) { label = U.makelabel(); target.attr('mylabel', label); } v.attr('targetlabel', label); } } } }); // Convert actions to text actionContainers.each(function () { const v = $(this); let astr = ''; const actions = v.children().not('constantpool'); const indent = [U.indentstr(actions.first())]; actions.each(function () { const a = $(this); if (a.attr('mylabel')) { astr += indent[0]; astr += a.attr('mylabel') + ':'; } if (a[0].tagName === 'end') { indent.pop(); } astr += indent.join(''); astr += actiontostring(a, ActionNames, $); if (a[0].tagName.indexOf('definefunction') === 0) { indent.push('\t'); } }); astr += U.indentstr(v); // Replace CDATA with text node (entity-encoded) v.empty().text(astr); }); // Resolve flags to symbolic names $('placeobject').each(function () { $(this).attr('flags', U.valuetoflags($(this).attr('flags'), C.PlaceObjectFlags)); }); $('buttonrecord').each(function () { $(this).attr('flags', U.valuetoflags($(this).attr('flags'), C.ButtonRecordFlags)); }); $('clipaction').each(function () { const $v = $(this); const flags = parseInt($v.attr('flags'), 10); const realflags = U.endian(flags & 0xffffff, 3); const keycode = (flags >> 24) & 0xff; $v.attr('flags', U.valuetoflags(realflags, C.ClipEventFlags)); $v.attr('keycode', keycode); }); $('buttonaction').each(function () { const $v = $(this); const flags = parseInt($v.attr('flags'), 10); $v.attr('flags', U.valuetoflags(flags, C.ButtonActionFlags)); }); // Image → texture name resolution (from manifest) if (manifest) { $('image').each(function () { const v = $(this); const id = v.attr('id'); const dd = dats[id]; if (typeof dd === 'string') { const tex = manifest(`Texture[id=apt_${baseName}_${dd}]`).attr('File'); if (tex) v.attr('image', tex); } else if (dd) { const tex = manifest(`Texture[id=apt_${baseName}_${id}]`).attr('File'); if (tex) v.attr('image', tex + ',' + dd.join(' ')); } }); } // Shape → embed .ru geometry $('shape').each(function () { const $v = $(this); const id = $v.attr('id'); let ff = null; if (manifest) { ff = manifest(`AptGeometryData[id=${baseName}_${id}]`).attr('File'); } else { ff = $v.attr('geometry'); } if (ff) { let ruData = ''; try { ruData = fs.readFileSync(filedir + ff, 'utf-8'); } catch (e) { // ru file might not exist } if (ruData) { let ru = '\n' + ruData; ru = ru.split(/[\r\n]+/); for (let i = 0; i < ru.length; i++) { ru[i] = ru[i].trim(); if (ru[i].indexOf('tc') > 0) { const ts = ru[i].split(':'); const img = $('image[id=' + ts[5] + ']'); ts[5] = img.length ? (img.attr('image') || ts[5]) : ts[5]; ru[i] = ts.join(':'); } } ru = ru.join('\n\t\t\t') + '\n\t\t'; $v.attr('geometry', ff); const ruNode = U.el('ru', $); ruNode.text(ru); $v.append('\n\t\t'); $v.append(ruNode); $v.append('\n\t'); } } }); // Clean up placeobject attributes based on flags $('placeobject').each(function () { const $v = $(this); const flags = $v.attr('flags') || ''; if (flags.indexOf('HasCharacter') < 0 || $v.attr('character') === '-1') { $v.removeAttr('character'); } if (flags.indexOf('HasMatrix') < 0) { $v.removeAttr('matrix'); } if (flags.indexOf('HasColorTransform') < 0) { $v.removeAttr('rgbamul rgbaadd'); } if (flags.indexOf('HasRatio') < 0) { $v.removeAttr('ratio'); } if (flags.indexOf('HasClipDepth') < 0) { $v.removeAttr('clipdepth'); } if (flags.indexOf('HasName') < 0) { $v.find('poname').remove(); } if (flags.indexOf('HasClipAction') < 0) { $v.find('clipactions').remove(); } }); // Clean up: remove extra attributes from the final output $('edittext').each(function () { const $v = $(this); // Override for xmladdhint: store original color info // (the original code swaps red/blue for edittext display - preserve that) }); // Clean internal attributes $('*').each(function () { const $v = $(this); $v.removeAttr('filtered mylabel targetlabel aln'); }); // Serialize let output = $.xml(); // Wrap action container text content in CDATA sections // This prevents cheerio from entity-encoding & in the action assembly text output = output.replace( /<(initaction|buttonaction|action|clipaction)(\s[^>]*)?>([\s\S]*?)<\/\1>/g, (match, tag, attrs, content) => { const trimmed = content.trim(); if (!trimmed) return match; return '<' + tag + (attrs || '') + '>'; } ); // Clean up excessive whitespace output = output.replace(/^[\t]+$/gm, '').replace(/[\r\n]+/g, '\n'); return output; } // ─── Decode XML entities in attribute values ─── // With decodeEntities: false in cheerio load, attribute values preserve XML entities // (e.g. & stays as &). We need to decode them for human-readable output. function decodeAttrEntities(s) { if (typeof s !== 'string') return s; return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'"); } // ─── actiontostring(): XML action node → text assembly ─── function actiontostring(a, ActionNames, $) { const action = parseInt(a.attr('action'), 10); let aname = ActionNames[action] || ('unknown_' + action); // Fix sign extension for pushbyte/pushshort if (action === C.EA_PUSHBYTE) { a.attr('val', (a.attr('val') << 24) >> 24); } else if (action === C.EA_PUSHSHORT) { a.attr('val', (a.attr('val') << 16) >> 16); } // Branch instructions: jump label_name if (action === C.ACTION_BRANCHALWAYS || action === C.ACTION_BRANCHIFTRUE || action === C.EA_BRANCHIFFALSE) { return aname + ' ' + a.attr('targetlabel'); } // Constant-referencing instructions: pushconst "str" or pushconst 123 if (action === C.EA_PUSHCONSTANT || action === C.EA_CALLNAMEDFUNCTIONPOP || action === C.EA_CALLNAMEDFUNCTION || action === C.EA_CALLNAMEDMETHODPOP || action === C.EA_CALLNAMEDMETHOD || action === C.EA_PUSHVALUEOFVAR || action === C.EA_GETNAMEDMEMBER || action === C.EA_PUSHWORDCONSTANT) { return aname + ' ' + (a.attr('str') !== undefined ? U.mkstr(decodeAttrEntities(a.attr('str'))) : a.attr('num')); } // goto: ActionGotoFrame pos if (a[0].tagName === 'goto') { return aname + ' ' + a.attr('pos'); } // geturl if (a[0].tagName === 'geturl') { return aname + ' ' + U.mkstr(decodeAttrEntities(a.attr('str1'))) + ', ' + U.mkstr(decodeAttrEntities(a.attr('str2'))); } // definefunction if (a[0].tagName === 'definefunction') { let s1 = 'function ' + a.attr('name') + '('; const args = []; a.children().each(function () { args.push($(this).attr('name')); }); s1 += args.join(', ') + ')'; return s1; } // definefunction2 if (a[0].tagName === 'definefunction2') { const flags = parseInt(a.attr('flags'), 10); const nregs = flags & 0xff; const realflags = U.endian((flags >> 8) & 0xffffff, 3); let s1 = 'function ' + a.attr('name') + '('; const aa = []; const aa2 = ['regs:' + nregs]; let r = 1; const fp = C.FunctionPreloadFlags; if (realflags & fp.PreloadThis) aa2.push('reg' + (r++) + ':this'); if (realflags & fp.PreloadArguments) aa2.push('reg' + (r++) + ':arguments'); if (realflags & fp.PreloadSuper) aa2.push('reg' + (r++) + ':super'); if (realflags & fp.PreloadRoot) aa2.push('reg' + (r++) + ':_root'); if (realflags & fp.PreloadParent) aa2.push('reg' + (r++) + ':_parent'); if (realflags & fp.PreloadGlobal) aa2.push('reg' + (r++) + ':_global'); if (realflags & fp.PreloadExtern) aa2.push('reg' + (r++) + ':_extern'); a.children().each(function () { aa.push('reg' + $(this).attr('reg') + ':' + $(this).attr('name')); }); s1 += aa.join(', ') + ')(' + aa2.join(', ') + ')'; return s1; } // end if (a[0].tagName === 'end') { return 'end'; } // noarg (single opcode with no arguments) if (a[0].tagName === 'noarg') { return aname; } // string instruction: pushstr "hello" if (a[0].tagName === 'string') { return aname + ' ' + U.mkstr(decodeAttrEntities(a.attr('str'))); } // pushdata: pushdata val1, val2, ... if (a[0].tagName === 'pushdata') { const vals = []; a.children().each(function () { const v = $(this); vals.push(v.attr('str') !== undefined ? U.mkstr(decodeAttrEntities(v.attr('str'))) : v.attr('num')); }); return aname + ' ' + vals.join(', '); } // Generic: actionname value if (a.attr('val') !== undefined) { return aname + ' ' + a.attr('val'); } return 'unknownaction_' + action; } // ─── Exports ─── module.exports = { expand };