Files
AptXmlAI/ra3apt-cli/lib/build.js
T
lanyi e0b8dedc04 Fix APT CLI roundtrip visual and script preservation
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.
2026-10-13 02:40:37 +02:00

1041 lines
36 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 build function ───
function build(input, output, opts) {
opts = opts || {};
// Read the human-readable XML
const humanXml = fs.readFileSync(input, 'utf-8');
const inputPath = path.resolve(input);
const inputDir = inputPath.substring(0, inputPath.lastIndexOf(path.sep)) + path.sep;
const inputName = inputPath.substring(inputPath.lastIndexOf(path.sep) + 1);
// Derive a default base name from the input file
let defaultBase = inputName;
if (defaultBase.endsWith('_edit.xml')) {
defaultBase = defaultBase.slice(0, -9);
} else if (defaultBase.endsWith('.xml')) {
defaultBase = defaultBase.slice(0, -4);
}
const defaultAptName = defaultBase + '.apt';
const outputPath = output || (inputDir + defaultAptName);
// Determine output directory and APT file name
const outDir = path.resolve(outputPath).substring(0, path.resolve(outputPath).lastIndexOf(path.sep)) + path.sep;
const outputAptName = path.basename(outputPath);
// Base name always comes from the output .apt file name
let baseName = outputAptName;
if (baseName.endsWith('.apt')) {
baseName = baseName.slice(0, -4);
} else if (baseName.endsWith('.const')) {
baseName = baseName.slice(0, -6);
}
const aptName = baseName + '.apt';
const tmpdir = U.createTempDir();
let success = false;
try {
// Run xmlcleanhint() transformation
const result = xmlcleanhint(humanXml, baseName, outDir, tmpdir, opts.oldStyle);
// Write raw XML to temp dir
U.writeRawXml(tmpdir, baseName, result.rawXml);
// Run xml2apt.exe
U.runXml2Apt(opts.toolsDir, tmpdir, baseName);
// Ensure output directory exists
fs.mkdirSync(outDir, { recursive: true });
// Copy .apt and .const to output directory
U.copyOutputFromTemp(outDir, tmpdir, baseName, aptName);
// Write manifest, .dat, .ru files
if (result.manifest) {
fs.writeFileSync(outDir + baseName + '.xml', result.manifest, 'utf-8');
}
if (result.dat) {
fs.writeFileSync(outDir + baseName + '.dat', result.dat, 'utf-8');
}
if (result.ruCache) {
for (const [rup, data] of Object.entries(result.ruCache)) {
// Ensure subdirectory exists for .ru files (e.g. geometry/2.ru)
fs.mkdirSync(path.dirname(rup), { recursive: true });
fs.writeFileSync(rup, data, 'utf-8');
}
}
success = true;
return outputPath;
} finally {
if (!opts.keepTemp) {
U.cleanupTempDir(tmpdir, false);
} else {
console.warn('Temporary files kept in: ' + tmpdir);
}
}
}
// ─── xmlcleanhint(): human-readable XML → raw apt XML ───
function xmlcleanhint(humanXml, baseName, outDir, tmpdir, oldStyle) {
U.resetLabels();
const $ = cheerio.load(humanXml, { xmlMode: true, decodeEntities: true });
$('aptdata').children().each(function () {
const character = $(this);
if (character.attr('comment') === 'not implemented yet!') {
throw new Error('cc3tools did not export the ' + this.tagName + ' character payload (code ' +
character.attr('code') + '); rebuilding this resource cannot preserve it');
}
});
const ActionNames = oldStyle ? C.ActionNames2 : C.ActionNames;
let isbad = false;
// ──────────────────────────────────────────
// 1. Parse text assembly back to action nodes
// ──────────────────────────────────────────
const actionContainers = $('initaction, buttonaction, action, clipaction');
let lastlabel = '';
actionContainers.each(function (j) {
if (isbad) return;
const v = $(this);
let text = v.text();
text = text.split(/[\r\n]+/);
v.empty();
const aa = [];
const fstk = [];
for (let i = 0; i < text.length; i++) {
const action = stringtoaction(text[i], ActionNames, $);
if (typeof action === 'string') {
if (action[0] !== ';') lastlabel = action;
} else if (action) {
if (action.is('bad')) {
isbad = true;
throw new Error('Incorrect script line: ' + text[i].trim() + '\n' +
'In container: ' + v[0].tagName);
}
if (lastlabel) {
action.attr('mylabel', lastlabel);
lastlabel = '';
}
if (action[0].tagName.indexOf('function') > 0) {
fstk.push([text[i], i]);
} else if (action[0].tagName === 'end') {
if (fstk.length === 0) {
throw new Error('Unexpected end of function');
}
fstk.pop();
}
aa.push(action);
}
}
if (fstk.length > 0) {
throw new Error('No end of function for: ' + fstk[0][0]);
}
v.append(aa);
});
// ──────────────────────────────────────────
// 2. Rebuild constant pools
// ──────────────────────────────────────────
const sortf = function (a, b) {
return a.tagName > b.tagName ? 1 : -1;
};
const actionConstants = $('data,' + C.CONST_ACTION_SELECTOR);
// Sort the collection
Array.prototype.sort.call(actionConstants, sortf);
const constmap = { str: Object.create(null), num: Object.create(null) };
actionConstants.each(function () {
if ($(this).is('[str]')) {
constmap.str[$(this).attr('str')] = 1;
} else if ($(this).is('[num]')) {
constmap.num[constantInteger($(this).attr('num'))] = 1;
}
});
let idx = 0;
const consttable = [];
for (const k in constmap.str) {
constmap.str[k] = idx;
consttable[idx++] = k;
}
for (const k in constmap.num) {
constmap.num[k] = idx;
consttable[idx++] = parseInt(k, 10);
}
// Replace constant references with pool indices
const dataConstants = $('data');
dataConstants.each(function () {
const $v = $(this);
if ($v.is('[str]')) {
$v.attr('val', constmap.str[$v.attr('str')]);
} else if ($v.is('[num]')) {
$v.attr('val', constmap.num[$v.attr('num')]);
}
});
let err = false;
actionContainers.each(function () {
const localstr = Object.create(null);
const localnum = Object.create(null);
const localconst = [];
let ci = 0;
const myactions = $(this).find(C.CONST_ACTION_SELECTOR);
Array.prototype.sort.call(myactions, sortf);
myactions.each(function () {
const a = $(this);
let li, k;
if (a.is('[str]')) {
k = a.attr('str');
if (localstr[k] === undefined) {
localstr[k] = ci;
localconst[ci++] = k;
}
li = localstr[k];
a.attr('val', li);
} else if (a.is('[num]')) {
k = a.attr('num');
if (localnum[k] === undefined) {
localnum[k] = ci;
localconst[ci++] = parseInt(k, 10);
}
li = localnum[k];
a.attr('val', li);
}
if (li > 255 && !a.is('short')) {
throw new Error('Byte value exceeds 255');
}
});
if (localconst.length) {
const ct = U.el('constantpool', $);
ct.attr('action', C.ACTION_CONSTANTPOOL);
for (let x = 0; x < localconst.length; x++) {
const cc = U.el('constant', $);
if (typeof localconst[x] === 'string') {
cc.attr('val', constmap.str[localconst[x]]);
cc.attr('str', localconst[x]);
} else {
cc.attr('val', constmap.num[localconst[x]]);
cc.attr('num', localconst[x]);
}
ct.append(cc);
}
$(this).prepend(ct);
}
});
// ──────────────────────────────────────────
// 3. Write .const binary file to tmpdir
// ──────────────────────────────────────────
for (const value of consttable) {
if (typeof value === 'string' && value.includes('\0')) throw new Error('String constants cannot contain NUL');
}
const cf = fs.openSync(tmpdir + baseName + '.const', 'w');
const constbuf = Buffer.alloc(0x20);
constbuf.asciiWrite('Apt constant file\x1A\0\0');
constbuf.writeUInt32LE(111, 0x14);
constbuf.writeUInt32LE(consttable.length, 0x18);
constbuf.writeUInt32LE(0x20, 0x1c);
fs.writeSync(cf, constbuf, 0, 0x20);
const tmpbuf = Buffer.alloc(8);
let strpos = 0x20 + 8 * consttable.length;
for (let i = 0; i < consttable.length; i++) {
const cv = consttable[i];
if (typeof cv === 'string') {
tmpbuf.writeUInt32LE(1);
tmpbuf.writeUInt32LE(strpos, 4);
const strbuf = Buffer.from(cv + '\0', 'utf8');
fs.writeSync(cf, strbuf, 0, strbuf.length, strpos);
strpos += strbuf.length;
strpos = Math.ceil(strpos / 4) * 4;
} else {
console.warn('Warning: integer constant (' + cv + ') stored in const table');
tmpbuf.writeUInt32LE(7);
tmpbuf.writeUInt32LE(constantInteger(cv) >>> 0, 4);
}
fs.writeSync(cf, tmpbuf, 0, 8, 0x20 + i * 8);
}
fs.closeSync(cf);
// ──────────────────────────────────────────
// 4. Re-number character IDs
// ──────────────────────────────────────────
const aptroot = $('aptdata');
$('movieclip').attr('id', 0);
let characters = aptroot.children();
// Sort by id
const charArray = [];
characters.each(function () { charArray.push(this); });
charArray.sort(function (a, b) {
const ida = isNaN(a.attribs.id) ? a.attribs.id : eval(a.attribs.id);
const idb = isNaN(b.attribs.id) ? b.attribs.id : eval(b.attribs.id);
if (ida > idb) return 1;
if (ida < idb) return -1;
throw new Error('Duplicate ids: ' + a.attribs.id);
});
// Re-attach sorted
charArray.forEach(function (el) { aptroot.append(el); });
const refs = aptroot.find('[character][character!="-1"],[font][font!="-1"],[sprite][sprite!="-1"]');
charArray.forEach(function (v, i) {
const $v = $(v);
const oldid = $v.attr('id');
const newid = i;
if ($v.attr('id') !== undefined) {
$v.attr('id', newid);
}
refs.filter('[character=' + oldid + ']').not('[filtered]').attr({ character: newid, filtered: '1' });
refs.filter('[font=' + oldid + ']').not('[filtered]').attr({ font: newid, filtered: '1' });
refs.filter('[sprite=' + oldid + ']').not('[filtered]').attr({ sprite: newid, filtered: '1' });
});
// Check for unresolved references
let unsolvedref = null;
refs.each(function () {
if (!$(this).attr('filtered')) {
unsolvedref = this;
return false;
}
});
if (unsolvedref) {
throw new Error('Reference not found: ' + $.html(unsolvedref));
}
// ──────────────────────────────────────────
// 5. Resolve jump targets
// ──────────────────────────────────────────
actionContainers.each(function () {
U.calcaln($(this).children('[action]'));
});
const gotos = $('[targetlabel][action]');
const dups = [];
gotos.each(function () {
const v = $(this);
const action = parseInt(v.attr('action'), 10);
const pos = parseInt(v.attr('pos'), 10);
const targetlabel = v.attr('targetlabel');
const list = v.parent().children();
const target = list.filter('[mylabel=' + targetlabel + ']');
if (target.length > 1) {
dups.push(targetlabel);
}
if (target.is('end')) {
const pr = target.prevAll('[action]:first');
const alnp = parseInt(pr.attr('aln'), 10);
target.attr('aln', alnp + U.alnsize(pr, alnp));
}
const nextaln = parseInt(v.nextAll('[action]:first').attr('aln'), 10);
const targetaln = parseInt(target.attr('aln'), 10);
v.attr('pos', targetaln - nextaln);
});
if (dups.length > 0) {
throw new Error('Duplicate labels: ' + dups.join(','));
}
// ──────────────────────────────────────────
// 6. Resolve function sizes
// ──────────────────────────────────────────
const functions = $('definefunction,definefunction2');
functions.each(function () {
const v = $(this);
let n = v.next();
let scope = 1;
while (n.length) {
if (n[0].tagName.indexOf('definefunction') === 0) {
scope++;
} else if (n[0].tagName === 'end') {
scope--;
if (!scope) {
const endn = n.prevAll('[aln]:first');
const alnend = parseInt(endn.attr('aln'), 10) + U.alnsize(endn, parseInt(endn.attr('aln'), 10));
const alnbegin = parseInt(v.attr('aln'), 10) + U.alnsize(v, parseInt(v.attr('aln'), 10));
v.attr('size', alnend - alnbegin);
return;
}
}
n = n.next();
}
});
// ──────────────────────────────────────────
// 7. Renumber frames
// ──────────────────────────────────────────
$('frames').each(function () {
$(this).children('frame').each(function (j) {
$(this).attr('id', j);
$(this).children('framelabel').attr('frame', j);
});
});
// ──────────────────────────────────────────
// 8. Convert symbolic flags back to numeric
// ──────────────────────────────────────────
$('placeobject').each(function () {
$(this).attr('flags', U.flagstovalue($(this).attr('flags') || '', C.PlaceObjectFlags));
});
$('buttonrecord').each(function () {
$(this).attr('flags', U.flagstovalue($(this).attr('flags'), C.ButtonRecordFlags));
});
$('clipaction').each(function () {
const $v = $(this);
const realflags = U.endian(U.flagstovalue($v.attr('flags'), C.ClipEventFlags), 3);
const keycode = parseInt($v.attr('keycode'), 10);
$v.attr('flags', realflags | (keycode << 24));
$v.removeAttr('keycode');
});
$('buttonaction').each(function () {
const $v = $(this);
$v.attr('flags', U.flagstovalue($v.attr('flags'), C.ButtonActionFlags));
});
// ──────────────────────────────────────────
// 8b. Convert rotm00/rotm01/... format → matrix for placeobject flag calculation
// The human-readable XML (expand output) uses expanded rotm00/tx/ty format,
// but the placeobject flag building below needs the compact `matrix` attribute.
// ──────────────────────────────────────────
$('*').each(function () {
const $v = $(this);
const rm00 = $v.attr('rotm00');
if (rm00 !== undefined) {
const rm01 = $v.attr('rotm01') || '0';
const rm10 = $v.attr('rotm10') || '0';
const rm11 = $v.attr('rotm11') || '1';
const tx = $v.attr('tx') || '0';
const ty = $v.attr('ty') || '0';
$v.attr('matrix', [rm00, rm01, rm10, rm11, tx, ty].join(','));
$v.removeAttr('rotm00 rotm01 rotm10 rotm11 tx ty');
}
// unknown (packed int) → rgbaadd (only if non-zero)
const unk = $v.is('placeobject') ? $v.attr('unknown') : undefined;
if (unk !== undefined && parseInt(unk, 10) !== 0) {
const u = parseInt(unk, 10);
$v.attr('rgbaadd', (u & 0xff) + ',' + ((u >> 8) & 0xff) + ',' +
((u >> 16) & 0xff) + ',' + ((u >> 24) & 0xff));
$v.removeAttr('unknown');
}
// Expanded cc3tools fields are the colour multiplier, not absent/default data.
if ($v.is('placeobject') && $v.attr('rgbamul') === undefined &&
['red', 'green', 'blue', 'alpha'].some(k => $v.attr(k) !== undefined)) {
$v.attr('rgbamul', ['red', 'green', 'blue', 'alpha'].map(k => $v.attr(k) ?? '255').join(','));
$v.removeAttr('red green blue alpha');
}
});
// ──────────────────────────────────────────
// 9. Build placeobject flags
// ──────────────────────────────────────────
$('placeobject').each(function () {
const $v = $(this);
let flags = parseInt($v.attr('flags'), 10) || 0;
// The flags may be symbolic (e.g. "HasCharacter|HasMatrix") — parse it
if (isNaN(parseInt($v.attr('flags'), 10))) {
// Symbolic flags: use them directly
flags = U.flagstovalue($v.attr('flags'), C.PlaceObjectFlags);
}
if ($v.attr('character') === '-1' || $v.attr('character') === undefined) {
$v.attr('character', '-1');
flags &= ~C.PlaceObjectFlags.HasCharacter;
} else {
flags |= C.PlaceObjectFlags.HasCharacter;
}
// Use compact `matrix` attr (converted from rotm00 in step 8b)
if ($v.attr('matrix') === undefined) {
$v.attr('matrix', '1,0,0,1,0,0');
flags &= ~C.PlaceObjectFlags.HasMatrix;
} else {
flags |= C.PlaceObjectFlags.HasMatrix;
}
// Color transform: presence of rgbamul or rgbaadd
if ($v.attr('rgbamul') === undefined && $v.attr('rgbaadd') === undefined) {
$v.attr('rgbamul', '255,255,255,255');
$v.attr('rgbaadd', '0,0,0,0');
flags &= ~C.PlaceObjectFlags.HasColorTransform;
} else {
flags |= C.PlaceObjectFlags.HasColorTransform;
}
if ($v.attr('ratio') === undefined) {
$v.attr('ratio', '0');
flags &= ~C.PlaceObjectFlags.HasRatio;
} else {
flags |= C.PlaceObjectFlags.HasRatio;
}
if ($v.attr('clipdepth') === undefined) {
$v.attr('clipdepth', '-1');
flags &= ~C.PlaceObjectFlags.HasClipDepth;
} else {
flags |= C.PlaceObjectFlags.HasClipDepth;
}
if ($v.find('poname').length === 0) {
flags &= ~C.PlaceObjectFlags.HasName;
} else {
flags |= C.PlaceObjectFlags.HasName;
}
// An active bit with a null clip-action pointer is valid stock data.
// Keep that bit without creating an array; clear it explicitly in input flags.
if ($v.find('clipactions').length !== 0) {
flags |= C.PlaceObjectFlags.HasClipAction;
}
$v.attr('flags', flags);
});
// ──────────────────────────────────────────
// 10. Handle shapes, images, .ru, manifest, .dat
// ──────────────────────────────────────────
let newmanifest = "<?xml version='1.0' encoding='utf-8'?>\n" +
'<AssetDeclaration xmlns="uri:ea.com:eala:asset" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">\n' +
'<AptAptData id="' + xmlAttribute(baseName + '_apt') + '" File="' + xmlAttribute(baseName + '.apt') + '" />\n' +
'<AptConstData id="' + xmlAttribute(baseName + '_const') + '" File="' + xmlAttribute(baseName + '.const') + '" />\n' +
'<AptDatData id="' + xmlAttribute(baseName + '_dat') + '" File="' + xmlAttribute(baseName + '.dat') + '" />\n';
let newdat = '; Created by AptXMLEditor.\n';
const texcache = {};
let imgid = aptroot.children().length - 1;
let texid = 0;
let ruerror = false;
const rucache = {};
$('shape').each(function () {
const sp = $(this);
const sid = sp.attr('id');
const ru = sp.find('ru');
let rupath = sp.attr('geometry');
let text = ru.text().replace(/t@c/g, 'tc');
text = text.split(/[\r\n]+/g);
let text2 = '';
for (let i = 0; i < text.length; i++) {
let txt = text[i].trim();
if (txt.indexOf('tc') > 0) {
txt = txt.split(':');
if (txt[5].indexOf('.') > 0) {
// filename reference → find or create image
let iid = $('image').filter(function () {
return $(this).attr('image').toLowerCase() === txt[5].toLowerCase();
}).attr('id');
if (iid === undefined) {
// Create new image
imgid++;
const newimg = U.el('image', $);
newimg.attr({ id: imgid, image: txt[5] });
aptroot.append(newimg);
iid = imgid;
}
txt[5] = iid;
} else {
// numeric id reference
const iid = txt[5];
if ($('image[id=' + iid + '],empty[id=' + iid + ']').length === 0) {
throw new Error('Reference not found: ' + rupath + ' => <image id="' + iid + '" ...');
}
}
txt = txt.join(':');
}
if (txt !== '') {
text2 += txt + '\n';
}
}
const rupathFull = (outDir + rupath).toLowerCase();
rucache[rupathFull] = text2;
newmanifest += '<AptGeometryData id="' + xmlAttribute(baseName + '_' + sid) + '" File="' + xmlAttribute(rupath) + '" AptID="' + sid + '"/>\n';
sp.attr('geometry', sid);
ru.remove();
});
// Process images
$('image').each(function () {
const v = $(this);
let img1 = v.attr('image');
const img = img1.toLowerCase();
const iid = v.attr('id');
if (!texcache[img]) {
let newid;
if (img.indexOf(',') > 0) {
newid = iid;
} else {
texid++;
newid = texid;
}
texcache[img] = newid;
newmanifest += '<Texture id="' + xmlAttribute('apt_' + baseName + '_' + newid) + '" File="' + xmlAttribute(img1.split(',')[0]) + '" OutputFormat="A8R8G8B8" GenerateMipMaps="false" AllowAutomaticResize="false"/>\n';
}
if (img.indexOf(',') > 0) {
newdat += iid + '=' + img.split(',')[1] + '\n';
} else {
newdat += iid + '->' + texcache[img] + '\n';
}
v.attr('image', iid);
});
newmanifest += '</AssetDeclaration>\n';
// ──────────────────────────────────────────
// 11. Attribute cleanup & transformation
// ──────────────────────────────────────────
$('*').removeAttr('filtered mylabel targetlabel aln newid');
// Process each element for attribute conversions
$('*').each(function () {
const $v = $(this);
let t;
// matrix → rotm00/rotm01/rotm10/rotm11/tx/ty
if ((t = $v.attr('matrix'))) {
const a = t.split(',');
$v.attr('rotm00', a[0]);
$v.attr('rotm01', a[1]);
$v.attr('rotm10', a[2]);
$v.attr('rotm11', a[3]);
$v.attr('tx', a[4]);
$v.attr('ty', a[5]);
$v.removeAttr('matrix');
}
// rgbamul/rgba → red,green,blue,alpha
if ((t = $v.attr('rgbamul')) || (t = $v.attr('rgba'))) {
const rgbam = t.split(',');
$v.attr('red', rgbam[0]);
$v.attr('green', rgbam[1]);
$v.attr('blue', rgbam[2]);
$v.attr('alpha', rgbam[3]);
$v.removeAttr('rgbamul rgba');
}
// rgbaadd → unknown (packed int)
if ((t = $v.attr('rgbaadd'))) {
const rgbaa = t.split(',');
$v.attr('unknown', parseInt(rgbaa[0], 10) |
(parseInt(rgbaa[1], 10) << 8) |
(parseInt(rgbaa[2], 10) << 16) |
(parseInt(rgbaa[3], 10) << 24));
$v.removeAttr('rgbaadd');
}
// color (comma-separated) → packed int
if ((t = $v.attr('color'))) {
const rgba = t.split(',');
$v.attr('color', parseInt(rgba[0], 10) |
(parseInt(rgba[1], 10) << 8) |
(parseInt(rgba[2], 10) << 16) |
(parseInt(rgba[3], 10) << 24));
}
// movieclip: framedelay → unknown
if ($v.is('movieclip') && $v.attr('framedelay') !== undefined) {
$v.attr('unknown', $v.attr('framedelay'));
$v.removeAttr('framedelay');
}
});
// edittext channels already use the same order as cc3tools expand output.
// Remove end/dummy markers
$('end,dummy').remove();
// Serialize
let output = $.xml();
// Literal attribute whitespace is normalised by XML readers; entities preserve it.
output = output.replace(/(\s[\w:.-]+=")([^"]*)"/g, (match, prefix, value) =>
prefix + value.replace(/\r/g, '&#13;').replace(/\n/g, '&#10;').replace(/\t/g, '&#9;') + '"');
output = output.replace(/^[\t]+$/gm, '').replace(/[\r\n]+/g, '\n');
return {
rawXml: output,
manifest: newmanifest,
dat: newdat,
ruCache: rucache
};
}
// ─── stringtoaction(): text assembly → XML action node ───
function stringtoaction(s, ActionNames, $) {
s = s.trim();
if (!s) return null;
// Comment line
if (s[0] === ';') {
return s;
}
let m;
// definefunction2: function name(args)(regs:N, reg1:this, ...)
if ((m = s.match(/^function[\s]+([\w]*)\(([^)]*)\)[\s]*\(([^)]*)\)/i))) {
const a = U.el('definefunction2', $);
a.attr('name', m[1] || '');
a.attr('action', C.ACTION_DEFINEFUNCTION2);
a.attr('size', 0);
const args = m[2].replace(/\s/g, '');
const args2 = m[3].replace(/\s/g, '');
const sysa = [...C.SYSTEM_ARGUMENTS];
const sysab = [...C.SYSTEM_ARGUMENTS];
let beginsys = false;
let lastsys = 0;
let realflags = C.FunctionPreloadFlags.SupressThis |
C.FunctionPreloadFlags.SupressArguments |
C.FunctionPreloadFlags.SupressSuper;
let nregs = 20;
if (args2) {
const parts = args2.split(',');
for (let i = 0; i < parts.length; i++) {
if (parts[i]) {
const am = parts[i].match(/^reg([s]|[0-9]+):([\w]+)/i);
if (am) {
const ar = am[1];
const an = am[2];
if (ar[0] === 's') {
nregs = parseInt(an, 10);
continue;
} else {
const arNum = parseInt(ar, 10);
if (sysa.indexOf(an) >= 0) {
if (!beginsys) {
if (arNum !== 1) return bad('system args must start from reg1', $);
beginsys = true;
}
lastsys++;
if (arNum !== lastsys) return bad('system args must be sequential', $);
while (sysa.shift() !== an) { /* empty */ }
const fp = C.FunctionPreloadFlags;
if (an === 'this') { realflags |= fp.PreloadThis; realflags &= ~fp.SupressThis; }
else if (an === 'arguments') { realflags |= fp.PreloadArguments; realflags &= ~fp.SupressArguments; }
else if (an === 'super') { realflags |= fp.PreloadSuper; realflags &= ~fp.SupressSuper; }
else if (an === '_root') { realflags |= fp.PreloadRoot; }
else if (an === '_parent') { realflags |= fp.PreloadParent; }
else if (an === '_global') { realflags |= fp.PreloadGlobal; }
else if (an === '_extern') { realflags |= fp.PreloadExtern; }
} else if (sysab.indexOf(an) >= 0) {
return bad('system argument in wrong order', $);
} else {
return bad('unknown argument type', $);
}
}
} else {
return bad('wrong argument format', $);
}
}
}
}
// Handle normal arguments (first part of the declaration)
if (args) {
const argParts = args.split(',');
for (let i = 0; i < argParts.length; i++) {
if (argParts[i]) {
const am = argParts[i].match(/^reg([0-9]+):([\w]+)/i);
if (am) {
const an = U.el('argument', $);
an.attr('reg', am[1]);
an.attr('name', am[2]);
a.append(an);
}
}
}
}
const flagsVal = (U.endian(realflags, 3) << 8) | (nregs & 0xff);
a.attr('flags', flagsVal);
return a;
}
// definefunction: function name(args)
if ((m = s.match(/^function[\s]+([\w]*)\(([^)]*)\)/i))) {
const a = U.el('definefunction', $);
a.attr('name', m[1] || '');
a.attr('action', C.ACTION_DEFINEFUNCTION);
a.attr('size', 0);
const args = m[2].replace(/\s/g, '');
if (args) {
const argParts = args.split(',');
for (let i = 0; i < argParts.length; i++) {
if (argParts[i]) {
const an = U.el('argument', $);
an.attr('name', argParts[i].trim());
a.append(an);
}
}
}
return a;
}
// label: (starts with ; or continues from previous line)
if (m = s.match(/^([\w]+)[\s]*:$/)) {
return m[1]; // return label name WITHOUT colon (caller uses it as mylabel)
}
// end of function
if (s === 'end') {
return U.el('end', $);
}
// Parse instruction: actionname [args...]
// Split into instruction name and arguments
const parts = s.match(/([\w]+)(?:\s+(.*))?/);
if (!parts) return null;
const instrName = parts[1];
const argStr = parts[2] ? parts[2].trim() : '';
// Look up the action code by name
let aid = -1;
// Prefer the current ActionNames table (might be old or new style)
for (let i = 0; i < ActionNames.length; i++) {
if (ActionNames[i] && ActionNames[i].toLowerCase() === instrName.toLowerCase()) {
aid = i;
break;
}
}
// If not found in current style, search both tables
if (aid < 0) {
for (let i = 0; i < C.ActionNames.length; i++) {
if (C.ActionNames[i] && C.ActionNames[i].toLowerCase() === instrName.toLowerCase()) {
aid = i;
break;
}
}
}
if (aid < 0) {
for (let i = 0; i < C.ActionNames2.length; i++) {
if (C.ActionNames2[i] && C.ActionNames2[i].toLowerCase() === instrName.toLowerCase()) {
aid = i;
break;
}
}
}
if (aid < 0) {
// Check for plain action name like "push" which might map to specific actions
return bad('unknown instruction: ' + instrName, $);
}
// Parse arguments based on action type
const operands = argStr ? parseArgs(argStr) : [];
const args = operands.map(operand => operand.value);
// Branch instructions: instr labelname
if (aid === C.ACTION_BRANCHALWAYS || aid === C.ACTION_BRANCHIFTRUE || aid === C.EA_BRANCHIFFALSE) {
const a = U.el('goto', $);
a.attr('action', aid);
a.attr('targetlabel', argStr);
return a;
}
// goto frame
if (aid === C.ACTION_GOTOFRAME || aid === C.ACTION_SETREGISTER ||
aid === C.ACTION_WITH || aid === C.ACTION_GOTOEXPRESSION) {
const a = U.el('goto', $);
a.attr('action', aid);
a.attr('pos', parseInt(argStr, 10) || 0);
return a;
}
// geturl: instr "url", "target"
if (aid === C.ACTION_GETURL) {
const a = U.el('geturl', $);
a.attr('action', aid);
a.attr('str1', args[0] || '');
a.attr('str2', args[1] || '');
return a;
}
// pushdata: instr val1, val2, ...
if (aid === C.ACTION_PUSHDATA) {
const a = U.el('pushdata', $);
a.attr('action', aid);
for (let i = 0; i < args.length; i++) {
const c = U.el('data', $);
if (operands[i].quoted || args[i] === '' || !isNumber(args[i])) {
c.attr('str', args[i] || '');
c.attr('val', 0);
} else {
c.attr('num', constantInteger(args[i]));
c.attr('val', 0);
}
a.append(c);
}
return a;
}
// String-type instructions: instr "string"
if (aid === C.EA_PUSHSTRING || aid === C.EA_GETSTRINGVAR ||
aid === C.EA_GETSTRINGMEMBER || aid === C.EA_SETSTRINGVAR ||
aid === C.EA_SETSTRINGMEMBER || aid === C.ACTION_SETTARGET ||
aid === C.ACTION_GOTOLABEL) {
const a = U.el('string', $);
a.attr('action', aid);
a.attr('str', args[0] || argStr.replace(/^"|"$/g, ''));
a.attr('val', 0);
return a;
}
// Constant-referencing: instr "str" or instr num
if (aid === C.EA_CALLNAMEDFUNCTIONPOP || aid === C.EA_CALLNAMEDFUNCTION ||
aid === C.EA_CALLNAMEDMETHODPOP || aid === C.EA_CALLNAMEDMETHOD ||
aid === C.EA_PUSHCONSTANT || aid === C.EA_GETNAMEDMEMBER ||
aid === C.EA_PUSHVALUEOFVAR) {
const a = U.el('byte', $);
a.attr('action', aid);
// Check if the original argument was quoted (indicates a string constant)
// e.g. pushconst "1" is a string, pushconst 1 is a number
if (operands[0]?.quoted) {
a.attr('str', args[0] || '');
} else {
if (args[0] === '' || !isNumber(args[0])) {
a.attr('str', args[0] || '');
} else {
a.attr('num', constantInteger(args[0]));
}
}
return a;
}
// pushbyte and pushregister take a byte
if (aid === C.EA_PUSHBYTE || aid === C.EA_PUSHREGISTER) {
const a = U.el('byte', $);
a.attr('action', aid);
a.attr('val', parseInt(args[0], 10) || 0);
return a;
}
// pushwordconstant
if (aid === C.EA_PUSHWORDCONSTANT) {
const a = U.el('short', $);
a.attr('action', aid);
if (operands[0]?.quoted) {
a.attr('str', args[0] || '');
} else {
if (args[0] === '' || !isNumber(args[0])) {
a.attr('str', args[0] || '');
} else {
a.attr('num', constantInteger(args[0]));
}
}
return a;
}
// pushshort
if (aid === C.EA_PUSHSHORT) {
const a = U.el('short', $);
a.attr('action', aid);
a.attr('val', parseInt(args[0], 10) || 0);
return a;
}
// pushfloat / pushlong
if (aid === C.EA_PUSHFLOAT || aid === C.EA_PUSHLONG) {
const tag = aid === C.EA_PUSHFLOAT ? 'float' : 'long';
const a = U.el(tag, $);
a.attr('action', aid);
if (aid === C.EA_PUSHFLOAT && args[0]) {
// Float is stored as a 4-byte value via parseInt
a.attr('val', parseFloat(args[0]));
} else {
a.attr('val', parseInt(args[0], 10) || 0);
}
return a;
}
// All other opcodes that have no arguments
if (!args.length || (args.length === 1 && args[0] === '')) {
const a = U.el('noarg', $);
a.attr('action', aid);
return a;
}
// Unknown action with unexpected args — make a noarg with val anyway
const a = U.el('noarg', $);
a.attr('action', aid);
a.attr('val', parseInt(args[0], 10) || 0);
return a;
}
// ─── Parse argument string ───
function parseArgs(argStr) {
const args = [];
let i = 0;
while (i < argStr.length) {
while (/\s/.test(argStr[i] || '') && i < argStr.length) i++;
const quoted = argStr[i] === '"';
let value = '';
if (quoted) {
i++;
let closed = false;
while (i < argStr.length) {
const c = argStr[i++];
if (c === '"') { closed = true; break; }
if (c === '\\' && i < argStr.length) {
const escaped = argStr[i++];
const escapes = { '"': '"', '\\': '\\', n: '\n', r: '\r', t: '\t' };
value += Object.hasOwn(escapes, escaped) ? escapes[escaped] : '\\' + escaped;
} else {
value += c;
}
}
if (!closed) throw new Error('Unterminated quoted argument');
while (/\s/.test(argStr[i] || '') && i < argStr.length) i++;
if (i < argStr.length && argStr[i] !== ',') throw new Error('Unexpected text after quoted argument');
} else {
while (i < argStr.length && argStr[i] !== ',') value += argStr[i++];
value = value.trim();
}
args.push({ value, quoted });
if (argStr[i] === ',') {
i++;
if (i === argStr.length) args.push({ value: '', quoted: false });
}
}
return args;
}
function isNumber(value) {
return typeof value === 'string' && /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value);
}
function constantInteger(value) {
const number = Number(value);
if (!Number.isInteger(number) || number < -0x80000000 || number > 0x7fffffff) {
throw new Error('Constant pool numbers must be signed 32-bit integers: ' + value);
}
return number;
}
function xmlAttribute(value) {
return String(value).replace(/&/g, '&amp;').replace(/"/g, '&quot;')
.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ─── bad() — create error marker element ───
function bad(msg, $) {
return $('<bad>').attr('text', msg);
}
// ─── Exports ───
module.exports = { build };