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.
This commit is contained in:
2026-10-13 02:40:37 +02:00
parent 5511354783
commit e0b8dedc04
18 changed files with 5111 additions and 115 deletions
+118
View File
@@ -0,0 +1,118 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const marker = 'RA3_CLI_TEST_RAW_XML_ENVELOPE\n';
const cliRoot = path.resolve(process.env.RA3_APT_CLI_ROOT || path.join(__dirname, '..'));
const runRoot = path.resolve(process.env.RA3_CLI_TEST_ARTIFACT_ROOT || path.join(cliRoot, '.work', 'tests'));
function decodeAttribute(value) {
return value.replace(/&(?:amp|quot|apos|lt|gt);|&#(?:x[0-9a-f]+|[0-9]+);/gi, item => {
const named = { '&amp;': '&', '&quot;': '"', '&apos;': "'", '&lt;': '<', '&gt;': '>' };
if (named[item] !== undefined) return named[item];
return String.fromCodePoint(item[2].toLowerCase() === 'x' ?
parseInt(item.slice(3, -1), 16) : parseInt(item.slice(2, -1), 10));
});
}
function attributes(tag) {
const result = {};
for (const match of tag.matchAll(/([\w:-]+)="([^"]*)"/g)) {
result[match[1]] = decodeAttribute(match[2]);
}
return result;
}
function element(xml, tag, attribute, value) {
const tags = Array.from(xml.matchAll(new RegExp(String.raw`<${tag}\b(?:[^>"']|"[^"]*"|'[^']*')*>`, 'g')),
match => attributes(match[0]));
const found = tags.filter(attrs => attrs[attribute] === String(value));
assert.equal(found.length, 1, `one ${tag} ${attribute}=${value} must exist`);
return found[0];
}
function frame(xml, id) {
const match = Array.from(xml.matchAll(/<frame\b([^>]*)>([\s\S]*?)<\/frame>/g))
.find(item => attributes(item[1]).id === String(id));
assert.ok(match, `frame ${id} must exist`);
return match[2];
}
function testCase(name) {
fs.mkdirSync(runRoot, { recursive: true });
const directory = fs.mkdtempSync(path.join(runRoot, name + '-'));
const temp = path.join(directory, 'temp');
const tools = path.join(directory, 'fake-tools');
fs.mkdirSync(temp);
fs.mkdirSync(tools);
return { directory, temp, tools };
}
function cli(test, command, input, output) {
const result = spawnSync(process.execPath, [
'--require', path.join(__dirname, 'fake-cc3tools.cjs'),
path.join(cliRoot, 'bin', 'ra3apt.js'), command, input,
'-o', output, '--tools-path', test.tools
], {
encoding: 'utf8', cwd: test.directory,
env: { ...process.env, TEMP: test.temp, TMP: test.temp, TMPDIR: test.temp }
});
fs.writeFileSync(path.join(test.directory, command + '.log'), result.stdout + result.stderr);
return result;
}
function succeeded(result) {
assert.equal(result.error, undefined);
assert.equal(result.status, 0, result.stderr || result.stdout);
}
function writeRaw(test, raw) {
const input = path.join(test.directory, 'fixture.apt');
fs.writeFileSync(input, marker + raw);
return input;
}
function rawOutput(output) {
const envelope = fs.readFileSync(output, 'utf8');
assert.ok(envelope.startsWith(marker));
return envelope.slice(marker.length);
}
function expanded(test, raw) {
const output = path.join(test.directory, 'fixture_edit.xml');
succeeded(cli(test, 'expand', writeRaw(test, raw), output));
return { file: output, xml: fs.readFileSync(output, 'utf8') };
}
function build(test, human, stem = 'rebuilt') {
const input = path.join(test.directory, stem + '_edit.xml');
const output = path.join(test.directory, stem + '.apt');
fs.writeFileSync(input, human);
const result = cli(test, 'build', input, output);
return { result, output, raw: result.status === 0 ? rawOutput(output) : null };
}
function constRecords(output) {
const table = fs.readFileSync(output.replace(/\.apt$/, '.const'));
const count = table.readUInt32LE(0x18);
const records = [];
for (let i = 0; i < count; i++) {
const offset = 0x20 + i * 8;
const type = table.readUInt32LE(offset);
const bits = table.readUInt32LE(offset + 4);
assert.ok(type === 1 || type === 7, 'these fixtures use existing string/integer CONST encodings');
if (type === 1) {
const end = table.indexOf(0, bits);
assert.ok(end >= bits);
records.push({ type, value: table.subarray(bits, end).toString('utf8'), offset: bits });
} else {
records.push({ type, bits, value: table.readInt32LE(offset + 4) });
}
}
return records;
}
module.exports = { assert, fs, path, attributes, element, frame, testCase, cli,
succeeded, writeRaw, rawOutput, expanded, build, constRecords, cliRoot };