first commit
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
'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 || '') + '><![CDATA[' + content + ']]></' + tag + '>';
|
||||
}
|
||||
);
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user