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.
1041 lines
36 KiB
JavaScript
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, ' ').replace(/\n/g, ' ').replace(/\t/g, '	') + '"');
|
|
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, '&').replace(/"/g, '"')
|
|
.replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
// ─── bad() — create error marker element ───
|
|
function bad(msg, $) {
|
|
return $('<bad>').attr('text', msg);
|
|
}
|
|
|
|
// ─── Exports ───
|
|
module.exports = { build };
|