Preserve edittext channels, alpha/color transforms, inactive placement fields, zero ratio/clipping and null ClipAction presence. Keep unlabelled flag bits, quoted/escaped script strings, export declarations and correctly typed CONST and pushdata values. Validate signed integers and write byte-aligned UTF-8. Add 23 public CLI regressions and record the native 174-resource batch plus bounded retests: four discovered pages are repaired, while the sound placeholder and native float formatting remain explicit accuracy limits. Rebuild the 1.0.1 Windows executable and leave unrelated SDK changes untouched.
402 lines
12 KiB
JavaScript
402 lines
12 KiB
JavaScript
'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: true }) : 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: true });
|
|
const ActionNames = oldStyle ? C.ActionNames2 : C.ActionNames;
|
|
const xmldoc = $;
|
|
|
|
// Preserve export order and repeated names, including Object prototype names.
|
|
|
|
// 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);
|
|
// Parse an XML CDATA node so serialization does not entity-encode assembly text.
|
|
v.empty().append('<![CDATA[' + astr.replace(/\]\]>/g, ']]]]><![CDATA[>') + ']]>');
|
|
});
|
|
|
|
// 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 rotm00 rotm01 rotm10 rotm11 tx ty');
|
|
}
|
|
if (flags.indexOf('HasColorTransform') < 0) {
|
|
$v.removeAttr('rgbamul rgbaadd red green blue alpha unknown');
|
|
}
|
|
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();
|
|
}
|
|
});
|
|
|
|
// Keep edittext channels in cc3tools' order; build uses the same order.
|
|
|
|
// Clean internal attributes
|
|
$('*').each(function () {
|
|
const $v = $(this);
|
|
$v.removeAttr('filtered mylabel targetlabel aln');
|
|
});
|
|
|
|
// Serialize
|
|
let output = $.xml();
|
|
// Clean up excessive whitespace
|
|
output = output.replace(/^[\t]+$/gm, '').replace(/[\r\n]+/g, '\n');
|
|
return output;
|
|
}
|
|
|
|
// ─── 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(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(a.attr('str1')) + ', ' + U.mkstr(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(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(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 };
|