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:
@@ -1,2 +1,5 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Temporary regression artifacts
|
||||
.work/
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# ra3apt-cli 1.0.1
|
||||
|
||||
Convert RA3 APT binaries to editable XML and build them using the bundled cc3tools converters.
|
||||
|
||||
```powershell
|
||||
ra3apt-cli.exe expand path/to/page.apt -o private/page_edit.xml
|
||||
ra3apt-cli.exe build private/page_edit.xml -o private/page.apt
|
||||
```
|
||||
|
||||
Keep the input APT's CONST, DAT, manifest XML and geometry together. Use `--tools-path` when cc3tools is not adjacent to the executable. Build in a private output directory: it also writes a CONST, manifest, DAT and geometry files. Textures referenced by the manifest must be supplied separately.
|
||||
|
||||
## Roundtrip repairs
|
||||
|
||||
Version 1.0.1 preserves raw edittext channels without the old asymmetric red/blue swap. These channel names follow cc3tools' dump convention; they must not be interpreted as an independent runtime RGB definition. For example raw edittext channels `(98,177,244)` correspond to packed color `0xFFF4B162`, whose rendered RGB is `(244,177,98)`.
|
||||
|
||||
PlaceObject color transforms and alpha are retained before flags are recalculated. Inactive matrix/color storage fields are removed during expansion, so a Move-only object no longer acquires an active matrix or color transform on build. Ratio and clipdepth flags are based on field presence, including an active zero value. The original HasClipAction bit is retained when its pointer is null, without creating an empty array. Remove HasClipAction explicitly from the input flags to clear that presence bit. Unlabelled flag bits are preserved as explicit hexadecimal terms such as 0x40; their engine meaning is not guessed.
|
||||
|
||||
Assembly strings now preserve quotes, backslashes, commas, control characters, ampersands and literal entity text with one XML decode. Export declarations retain their original order and repeated names. Constant pools accept names such as `constructor` and `__proto__`; pushdata uses the actual data schema and retains quoted string types. Type-7 integers use signed32 validation and preserve negative bit patterns. Fractional/overflow operands and embedded NUL constants are rejected instead of silently changing values. Strings are stored as variable-length UTF-8 with byte-based pointers and alignment.
|
||||
|
||||
Re-expand the original APT with this version before editing. Old saved edit XML may contain inactive default fields or ambiguous backslashes from the old expansion and is not automatically migrated.
|
||||
|
||||
## Accuracy boundary
|
||||
|
||||
This tool does not promise byte-exact APT/CONST roundtrips. IDs, pool ordering and manifest/DAT declarations may be rebuilt. cc3tools emits many float32 values to six decimal places; this loses precision before the JavaScript layer receives them. A script `pushfloat 0.3333333432674408` becomes `0.333333` in native output and rebuilds to different float32 bits. Equal expanded XML cannot establish original float32 equality. Repairing that loss requires a precise native formatter or a separately designed binary preservation mechanism.
|
||||
|
||||
The sampleMissionBriefing baseline contains a real type-6 sound character. cc3tools exports it only as a not-implemented placeholder without its payload/id. Build now stops with a specific diagnostic; this resource cannot be rebuilt accurately through the current native XML representation.
|
||||
|
||||
UTF-8 byte preservation does not establish engine font/encoding support. Native conversion and XML checks do not substitute for in-game testing of edited resources. See `ROUNDTRIP_REPORT.md` for the actual 2026-10-13 resource results.
|
||||
|
||||
## Development
|
||||
|
||||
Install normal dependencies, run `npm test`, and build the Windows executable with `npm run build:exe` (Node 24 target). Tests and their limits are documented in `test/README.md`. Direct test artifacts with `RA3_CLI_TEST_ARTIFACT_ROOT`; generated files belong in `.work/` and must not be committed.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Native roundtrip verification — 2026-10-13
|
||||
|
||||
The demonstrated JavaScript defects are repairable and have been fixed. **Globally exact roundtrips are not established**: native float32 output loses precision, and one SDK resource's sound character cannot be rebuilt from the native XML placeholder.
|
||||
|
||||
## Full resource regression
|
||||
|
||||
All **174** SDK `Data/original-aptui` APTs were tested through the real public Node CLI and cc3tools in three isolated batches of 58. The first repair draft produced these results:
|
||||
|
||||
| Result | Pages | Finding |
|
||||
| --- | ---: | --- |
|
||||
| Expanded UI fields equal | 169 | No represented script/layout/color/geometry differences |
|
||||
| Expanded UI flag changed | 3 | Empty HasClipAction array omitted by apt2xml, then presence bit cleared by build |
|
||||
| expand rejected | 1 | VerticalScrollBar button 171, third buttonaction flags=64; missing symbolic name for 0x40 |
|
||||
| build rejected | 1 | sampleMissionBriefing contains a valid sound character that cc3tools exports incompletely |
|
||||
|
||||
The final repair preserves the original active clip-action bit with its null pointer, without adding an empty array, in HUDVoiceChatControlPanel, TacticalHUDBeaconStage and TacticalQuickChatDialog. It preserves unlabelled flag bits explicitly as hexadecimal terms, without assigning guessed engine meanings. Fresh final-source and final-EXE native roundtrips on all four affected pages had **zero expanded UI differences**. sampleMissionBriefing now fails with an accurate unsupported sound-payload diagnostic instead of Duplicate ids: undefined.
|
||||
|
||||
Thus **173/174** resources have successful, matching expanded representations using the first-draft results plus the four bounded retests. This is not a claim that all 174 were rerun with the final source, nor that the original float32 values are bit-exact. The later code changes affect missing empty-array markers, previously unknown flag bits and native not-implemented placeholders; the original successful cases and raw comparisons provide the unchanged-path evidence.
|
||||
|
||||
The XML comparison included every represented attribute, child order, instruction/string and RU geometry record. Only XML indentation/blank lines and outer assembly/RU line indentation were normalized. Native raw checks covered the **172** initially successful pages separately, including fields omitted by the human-readable expansion. Their **7136 definefunction2** records retained flags, sizes and argument fields; there is no observed Suppress-flags loss in this corpus. Raw differences comprised 28500 constant-pool record/order changes, 88621 pool-reference index changes and the three known clip-action flags; there were no other raw field/structure changes. Final-EXE raw retests of the four repaired pages and author loading/disconnect pages retained all non-pool raw fields.
|
||||
|
||||
All 172 initially successful pages retained resolved DAT -> Texture declaration -> material file/format/mipmap/resize attributes. Original raw manifest/DAT order and numbering differences were retained as differences; a separate check compared resolved attributes, unused texture declarations and other manifest declarations. Texture pixels were not copied or rendered. Only 4 APTs and 15 CONSTs were byte-identical in the initial native batch; most are repooled/relocated, and equal raw decimal output cannot prove all original float bits.
|
||||
|
||||
## Author pages and targeted tests
|
||||
|
||||
The first repair source and first EXE draft also tested all eight author Core13 modified pages with zero expanded UI differences and unchanged resolved resources. Their native raw differences were pool records/indices only; all function2 fields matched. Final-source/EXE changes were independently checked on the fault pages, and the final EXE again tested the author loading/disconnect pages. No author-before package was inferred: Core13 modified APT/CONST/geometry streams were combined with SDK manifest/DAT descriptions and author geometry overrides.
|
||||
|
||||
All **23** public CLI tests passed after installing the final source, with no skips. They use the actual Node dispatcher, XML transformations and CONST writer, substituting explicit test envelopes only at the cc3tools subprocess boundary. Tests cover deliberate height/RGB/translation edits with unrelated-field assertions, alpha/additive flags, active zero ratio/clipping, empty clip arrays, quotes/backslashes/commas/entities/CDATA, prototype-name pools/exports, signed32/overflow/fractional rejection, UTF-8 byte lengths/alignment, pushdata and the two full-corpus diagnostics. See test/README.md for the transport limit.
|
||||
|
||||
Eight previously failing minimal raw APT cases were also rebuilt through real converters with the final source; expanded state, exports and decoded action attributes matched. Separate real native cases retained 512-byte ASCII and Chinese UTF-8 constants and rejected pushconst 1.25. UTF-8 readback proves stored bytes and decoding here, not game font support.
|
||||
|
||||
## Remaining native limitations
|
||||
|
||||
1. **Float precision:** apt2xml emits many float32 fields with six decimal places. A valid pushfloat 0.3333333432674408 writes opcode/payload b4 ab aa aa 3e, but expands to 0.333333 and rebuilds to b4 9f aa aa 3e at offset 132. The final CLI still reproduces this loss. Both binaries expand to the same decimal text. A precise native formatter or separately designed binary-preservation method is required; the JavaScript parser cannot recover digits already lost.
|
||||
2. **Sound payload:** sampleMissionBriefing has a valid non-null character at slot 31, APT offset 932, type 6, signature 0x09876543. Native apt2xml produces only `<sound code="6" comment="not implemented yet!"/>`, without its character ID/payload. The CLI now identifies that limitation and stops; it does not delete the character, replace it with null, or declare the original input invalid. Supporting this page requires native type-6 write support or a verified preservation path.
|
||||
|
||||
Native converter binaries were unchanged. Therefore these repairs do not justify saying every original APT can undergo an accurate, lossless roundtrip. Game resource replacement and gameplay acceptance remain separate from this CLI regression.
|
||||
|
||||
## Identity and reproduction
|
||||
|
||||
Starting source revision: **5511354**, aptxmlai Git repository. The initial source was frozen throughout the three original batches and the eight-page author/EXE batches; later fixes were isolated in separate source copies. test/original-roundtrip-inputs.json records all 174 relative APT paths plus APT/CONST/DAT/manifest input sizes and SHA-256 identities.
|
||||
|
||||
Author Core13 SHA-256: `0d22861bff7e1b947215017d2c3e9e0d632dada9c6ea7af48a3d78ebeec28c49`. Final Windows EXE: **1.0.1**, 94110008 bytes, SHA-256 `341d53a34bff107d60d16a34b0401930e00f13f22917ef355d2e2153353548a0`. Built with installed @yao-pkg/pkg 6.12.0 and cached Node 24 Windows runtime. Source tests and final executable tests are reported separately above.
|
||||
|
||||
For each original, run expand to a private same-basename edit XML, build to a private same-basename APT, then expand that output and compare. Use explicit --tools-path and keep original CONST/DAT/manifest/geometry together. Compare manifest/DAT separately, resolve coordinated numeric indices, and run raw apt2xml on private copies to inspect fields hidden by expansion. Re-expand with this CLI before editing: older edit XML may contain inactive defaults or ambiguous string escapes.
|
||||
|
||||
Transient scripts, logs, command/identity receipts and full diffs were isolated under the RA3 analysis workspace `.work/ui_author_compare_20261012/cli_fix_20261013`. Commands recorded begin/end state before proceeding and bound TEMP/TMP/cwd to private directories. Large duplicate texts were losslessly gzipped with verified original hashes; bulk generated binaries/payloads were cleaned after analysis while retaining receipts/diffs and small failure evidence. No game was launched for this repair, no priority resource or production Native source was changed, and unrelated SDK working-tree changes were left intact.
|
||||
@@ -1,3 +1,7 @@
|
||||
# Historical porting plan
|
||||
|
||||
The original phase checklist below describes version 1.0.0. Its sections 4.1, 4.2, 4.6 and 4.7 are superseded by the 1.0.1 single-decode, typed-operand and variable-length UTF-8 implementation. Use README.md, test/README.md and ROUNDTRIP_REPORT.md for current behavior and validation.
|
||||
|
||||
# RA3 Apt CLI — 工作计划
|
||||
|
||||
> 将 ra3aptxmleditor 的 .apt ↔ 人类可读 XML 双向转换流程 CLI 化。
|
||||
|
||||
+95
-63
@@ -87,7 +87,14 @@ function build(input, output, opts) {
|
||||
function xmlcleanhint(humanXml, baseName, outDir, tmpdir, oldStyle) {
|
||||
U.resetLabels();
|
||||
|
||||
const $ = cheerio.load(humanXml, { xmlMode: true, decodeEntities: false });
|
||||
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;
|
||||
|
||||
@@ -147,12 +154,12 @@ function xmlcleanhint(humanXml, baseName, outDir, tmpdir, oldStyle) {
|
||||
// Sort the collection
|
||||
Array.prototype.sort.call(actionConstants, sortf);
|
||||
|
||||
const constmap = { str: {}, num: {} };
|
||||
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[$(this).attr('num')] = 1;
|
||||
constmap.num[constantInteger($(this).attr('num'))] = 1;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -180,8 +187,8 @@ function xmlcleanhint(humanXml, baseName, outDir, tmpdir, oldStyle) {
|
||||
|
||||
let err = false;
|
||||
actionContainers.each(function () {
|
||||
const localstr = {};
|
||||
const localnum = {};
|
||||
const localstr = Object.create(null);
|
||||
const localnum = Object.create(null);
|
||||
const localconst = [];
|
||||
let ci = 0;
|
||||
const myactions = $(this).find(C.CONST_ACTION_SELECTOR);
|
||||
@@ -233,6 +240,9 @@ function xmlcleanhint(humanXml, baseName, outDir, tmpdir, oldStyle) {
|
||||
// ──────────────────────────────────────────
|
||||
// 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');
|
||||
@@ -242,7 +252,6 @@ function xmlcleanhint(humanXml, baseName, outDir, tmpdir, oldStyle) {
|
||||
fs.writeSync(cf, constbuf, 0, 0x20);
|
||||
|
||||
const tmpbuf = Buffer.alloc(8);
|
||||
const strbuf = Buffer.alloc(512);
|
||||
let strpos = 0x20 + 8 * consttable.length;
|
||||
|
||||
for (let i = 0; i < consttable.length; i++) {
|
||||
@@ -250,14 +259,14 @@ function xmlcleanhint(humanXml, baseName, outDir, tmpdir, oldStyle) {
|
||||
if (typeof cv === 'string') {
|
||||
tmpbuf.writeUInt32LE(1);
|
||||
tmpbuf.writeUInt32LE(strpos, 4);
|
||||
strbuf.asciiWrite(cv + '\0');
|
||||
fs.writeSync(cf, strbuf, 0, cv.length + 1, strpos);
|
||||
strpos += cv.length + 1;
|
||||
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(cv, 4);
|
||||
tmpbuf.writeUInt32LE(constantInteger(cv) >>> 0, 4);
|
||||
}
|
||||
fs.writeSync(cf, tmpbuf, 0, 8, 0x20 + i * 8);
|
||||
}
|
||||
@@ -417,13 +426,19 @@ function xmlcleanhint(humanXml, baseName, outDir, tmpdir, oldStyle) {
|
||||
$v.removeAttr('rotm00 rotm01 rotm10 rotm11 tx ty');
|
||||
}
|
||||
// unknown (packed int) → rgbaadd (only if non-zero)
|
||||
const unk = $v.attr('unknown');
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
@@ -460,13 +475,13 @@ function xmlcleanhint(humanXml, baseName, outDir, tmpdir, oldStyle) {
|
||||
} else {
|
||||
flags |= C.PlaceObjectFlags.HasColorTransform;
|
||||
}
|
||||
if ($v.attr('ratio') === undefined || $v.attr('ratio') === '0') {
|
||||
if ($v.attr('ratio') === undefined) {
|
||||
$v.attr('ratio', '0');
|
||||
flags &= ~C.PlaceObjectFlags.HasRatio;
|
||||
} else {
|
||||
flags |= C.PlaceObjectFlags.HasRatio;
|
||||
}
|
||||
if ($v.attr('clipdepth') === '-1' || $v.attr('clipdepth') === undefined) {
|
||||
if ($v.attr('clipdepth') === undefined) {
|
||||
$v.attr('clipdepth', '-1');
|
||||
flags &= ~C.PlaceObjectFlags.HasClipDepth;
|
||||
} else {
|
||||
@@ -477,9 +492,9 @@ function xmlcleanhint(humanXml, baseName, outDir, tmpdir, oldStyle) {
|
||||
} else {
|
||||
flags |= C.PlaceObjectFlags.HasName;
|
||||
}
|
||||
if ($v.find('clipactions').length === 0) {
|
||||
flags &= ~C.PlaceObjectFlags.HasClipAction;
|
||||
} else {
|
||||
// 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);
|
||||
@@ -490,9 +505,9 @@ function xmlcleanhint(humanXml, baseName, outDir, tmpdir, oldStyle) {
|
||||
// ──────────────────────────────────────────
|
||||
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="' + baseName + '_apt" File="' + baseName + '.apt" />\n' +
|
||||
'<AptConstData id="' + baseName + '_const" File="' + baseName + '.const" />\n' +
|
||||
'<AptDatData id="' + baseName + '_dat" File="' + baseName + '.dat" />\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 = {};
|
||||
@@ -545,7 +560,7 @@ function xmlcleanhint(humanXml, baseName, outDir, tmpdir, oldStyle) {
|
||||
|
||||
const rupathFull = (outDir + rupath).toLowerCase();
|
||||
rucache[rupathFull] = text2;
|
||||
newmanifest += '<AptGeometryData id="' + baseName + '_' + sid + '" File="' + rupath + '" AptID="' + sid + '"/>\n';
|
||||
newmanifest += '<AptGeometryData id="' + xmlAttribute(baseName + '_' + sid) + '" File="' + xmlAttribute(rupath) + '" AptID="' + sid + '"/>\n';
|
||||
sp.attr('geometry', sid);
|
||||
ru.remove();
|
||||
});
|
||||
@@ -566,7 +581,7 @@ function xmlcleanhint(humanXml, baseName, outDir, tmpdir, oldStyle) {
|
||||
newid = texid;
|
||||
}
|
||||
texcache[img] = newid;
|
||||
newmanifest += '<Texture id="apt_' + baseName + '_' + newid + '" File="' + img1.split(',')[0] + '" OutputFormat="A8R8G8B8" GenerateMipMaps="false" AllowAutomaticResize="false"/>\n';
|
||||
newmanifest += '<Texture id="' + xmlAttribute('apt_' + baseName + '_' + newid) + '" File="' + xmlAttribute(img1.split(',')[0]) + '" OutputFormat="A8R8G8B8" GenerateMipMaps="false" AllowAutomaticResize="false"/>\n';
|
||||
}
|
||||
|
||||
if (img.indexOf(',') > 0) {
|
||||
@@ -637,20 +652,16 @@ function xmlcleanhint(humanXml, baseName, outDir, tmpdir, oldStyle) {
|
||||
}
|
||||
});
|
||||
|
||||
// Swap red/blue for edittext
|
||||
$('edittext').each(function () {
|
||||
const $v = $(this);
|
||||
const r = $v.attr('red');
|
||||
const b = $v.attr('blue');
|
||||
$v.attr('red', b);
|
||||
$v.attr('blue', r);
|
||||
});
|
||||
// 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 {
|
||||
@@ -826,7 +837,8 @@ function stringtoaction(s, ActionNames, $) {
|
||||
}
|
||||
|
||||
// Parse arguments based on action type
|
||||
const args = argStr ? parseArgs(argStr) : [];
|
||||
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) {
|
||||
@@ -859,13 +871,12 @@ function stringtoaction(s, ActionNames, $) {
|
||||
const a = U.el('pushdata', $);
|
||||
a.attr('action', aid);
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const c = U.el('constant', $);
|
||||
const numVal = parseInt(args[i], 10);
|
||||
if (isNaN(numVal) || args[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', numVal);
|
||||
c.attr('num', constantInteger(args[i]));
|
||||
c.attr('val', 0);
|
||||
}
|
||||
a.append(c);
|
||||
@@ -894,14 +905,13 @@ function stringtoaction(s, ActionNames, $) {
|
||||
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 (argStr && argStr[0] === '"') {
|
||||
if (operands[0]?.quoted) {
|
||||
a.attr('str', args[0] || '');
|
||||
} else {
|
||||
const numVal = parseInt(args[0], 10);
|
||||
if (isNaN(numVal) || args[0] === '') {
|
||||
if (args[0] === '' || !isNumber(args[0])) {
|
||||
a.attr('str', args[0] || '');
|
||||
} else {
|
||||
a.attr('num', numVal);
|
||||
a.attr('num', constantInteger(args[0]));
|
||||
}
|
||||
}
|
||||
return a;
|
||||
@@ -919,14 +929,13 @@ function stringtoaction(s, ActionNames, $) {
|
||||
if (aid === C.EA_PUSHWORDCONSTANT) {
|
||||
const a = U.el('short', $);
|
||||
a.attr('action', aid);
|
||||
if (argStr && argStr[0] === '"') {
|
||||
if (operands[0]?.quoted) {
|
||||
a.attr('str', args[0] || '');
|
||||
} else {
|
||||
const numVal = parseInt(args[0], 10);
|
||||
if (isNaN(numVal) || args[0] === '') {
|
||||
if (args[0] === '' || !isNumber(args[0])) {
|
||||
a.attr('str', args[0] || '');
|
||||
} else {
|
||||
a.attr('num', numVal);
|
||||
a.attr('num', constantInteger(args[0]));
|
||||
}
|
||||
}
|
||||
return a;
|
||||
@@ -970,33 +979,56 @@ function stringtoaction(s, ActionNames, $) {
|
||||
// ─── Parse argument string ───
|
||||
function parseArgs(argStr) {
|
||||
const args = [];
|
||||
let current = '';
|
||||
let inQuote = false;
|
||||
|
||||
for (let i = 0; i < argStr.length; i++) {
|
||||
const c = argStr[i];
|
||||
if (c === '"') {
|
||||
inQuote = !inQuote;
|
||||
current += c;
|
||||
} else if (c === ',' && !inQuote) {
|
||||
args.push(current.trim());
|
||||
current = '';
|
||||
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 {
|
||||
current += c;
|
||||
value += c;
|
||||
}
|
||||
}
|
||||
if (current.trim()) {
|
||||
args.push(current.trim());
|
||||
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;
|
||||
}
|
||||
|
||||
// Strip surrounding quotes from each argument (matching original eval() behavior)
|
||||
return args.map(s => {
|
||||
const t = s.trim();
|
||||
if (t.length >= 2 && t[0] === '"' && t[t.length - 1] === '"') {
|
||||
return t.slice(1, -1);
|
||||
function isNumber(value) {
|
||||
return typeof value === 'string' && /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value);
|
||||
}
|
||||
return t;
|
||||
});
|
||||
|
||||
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 ───
|
||||
|
||||
+12
-44
@@ -26,7 +26,7 @@ function expand(input, output, opts) {
|
||||
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;
|
||||
const manifest = manifestData ? cheerio.load(manifestData, { xmlMode: true, decodeEntities: true }) : null;
|
||||
|
||||
// 5. Parse .dat if available
|
||||
const dats = parseDat(datData);
|
||||
@@ -69,20 +69,11 @@ function parseDat(datData) {
|
||||
function xmladdhint(rawXml, baseName, aptName, manifest, dats, filedir, oldStyle) {
|
||||
U.resetLabels();
|
||||
|
||||
const $ = cheerio.load(rawXml, { xmlMode: true, decodeEntities: false });
|
||||
const $ = cheerio.load(rawXml, { xmlMode: true, decodeEntities: true });
|
||||
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;
|
||||
}
|
||||
});
|
||||
// Preserve export order and repeated names, including Object prototype names.
|
||||
|
||||
// Calculate aligned positions for each action container
|
||||
const actionContainers = $('initaction, buttonaction, action, clipaction');
|
||||
@@ -179,8 +170,8 @@ function xmladdhint(rawXml, baseName, aptName, manifest, dats, filedir, oldStyle
|
||||
}
|
||||
});
|
||||
astr += U.indentstr(v);
|
||||
// Replace CDATA with text node (entity-encoded)
|
||||
v.empty().text(astr);
|
||||
// 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
|
||||
@@ -268,10 +259,10 @@ function xmladdhint(rawXml, baseName, aptName, manifest, dats, filedir, oldStyle
|
||||
$v.removeAttr('character');
|
||||
}
|
||||
if (flags.indexOf('HasMatrix') < 0) {
|
||||
$v.removeAttr('matrix');
|
||||
$v.removeAttr('matrix rotm00 rotm01 rotm10 rotm11 tx ty');
|
||||
}
|
||||
if (flags.indexOf('HasColorTransform') < 0) {
|
||||
$v.removeAttr('rgbamul rgbaadd');
|
||||
$v.removeAttr('rgbamul rgbaadd red green blue alpha unknown');
|
||||
}
|
||||
if (flags.indexOf('HasRatio') < 0) {
|
||||
$v.removeAttr('ratio');
|
||||
@@ -287,12 +278,7 @@ function xmladdhint(rawXml, baseName, aptName, manifest, dats, filedir, oldStyle
|
||||
}
|
||||
});
|
||||
|
||||
// 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)
|
||||
});
|
||||
// Keep edittext channels in cc3tools' order; build uses the same order.
|
||||
|
||||
// Clean internal attributes
|
||||
$('*').each(function () {
|
||||
@@ -302,29 +288,11 @@ function xmladdhint(rawXml, baseName, aptName, manifest, dats, filedir, oldStyle
|
||||
|
||||
// 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);
|
||||
@@ -347,7 +315,7 @@ function actiontostring(a, ActionNames, $) {
|
||||
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'));
|
||||
return aname + ' ' + (a.attr('str') !== undefined ? U.mkstr(a.attr('str')) : a.attr('num'));
|
||||
}
|
||||
|
||||
// goto: ActionGotoFrame pos
|
||||
@@ -357,7 +325,7 @@ function actiontostring(a, ActionNames, $) {
|
||||
|
||||
// geturl
|
||||
if (a[0].tagName === 'geturl') {
|
||||
return aname + ' ' + U.mkstr(decodeAttrEntities(a.attr('str1'))) + ', ' + U.mkstr(decodeAttrEntities(a.attr('str2')));
|
||||
return aname + ' ' + U.mkstr(a.attr('str1')) + ', ' + U.mkstr(a.attr('str2'));
|
||||
}
|
||||
|
||||
// definefunction
|
||||
@@ -408,7 +376,7 @@ function actiontostring(a, ActionNames, $) {
|
||||
|
||||
// string instruction: pushstr "hello"
|
||||
if (a[0].tagName === 'string') {
|
||||
return aname + ' ' + U.mkstr(decodeAttrEntities(a.attr('str')));
|
||||
return aname + ' ' + U.mkstr(a.attr('str'));
|
||||
}
|
||||
|
||||
// pushdata: pushdata val1, val2, ...
|
||||
@@ -416,7 +384,7 @@ function actiontostring(a, ActionNames, $) {
|
||||
const vals = [];
|
||||
a.children().each(function () {
|
||||
const v = $(this);
|
||||
vals.push(v.attr('str') !== undefined ? U.mkstr(decodeAttrEntities(v.attr('str'))) : v.attr('num'));
|
||||
vals.push(v.attr('str') !== undefined ? U.mkstr(v.attr('str')) : v.attr('num'));
|
||||
});
|
||||
return aname + ' ' + vals.join(', ');
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ function valuetoflags(v, e) {
|
||||
}
|
||||
}
|
||||
if (v0) {
|
||||
throw new Error('unknown bit flags ' + v0 + ' in ' + v);
|
||||
// Keep unlabelled converter bits explicit, without assigning guessed meanings.
|
||||
keys.push('0x' + (v0 >>> 0).toString(16));
|
||||
}
|
||||
return keys.join('|');
|
||||
}
|
||||
@@ -48,6 +49,8 @@ function flagstovalue(f, e) {
|
||||
const k = e[parts[i]];
|
||||
if (k !== undefined) {
|
||||
v |= k;
|
||||
} else if (/^0x[0-9a-f]{1,8}$/i.test(parts[i])) {
|
||||
v |= Number(parts[i]);
|
||||
} else {
|
||||
throw new Error('invalid bit flag: ' + parts[i]);
|
||||
}
|
||||
@@ -60,8 +63,10 @@ function encodeXML(s) {
|
||||
const a = new Array(s.length);
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
if (c === '\r') a[i] = '\\r';
|
||||
if (c === '\\') a[i] = '\\\\';
|
||||
else if (c === '\r') a[i] = '\\r';
|
||||
else if (c === '\n') a[i] = '\\n';
|
||||
else if (c === '\t') a[i] = '\\t';
|
||||
else if (c === '"') a[i] = '\\"';
|
||||
else a[i] = c;
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ra3apt-cli",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ra3apt-cli",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "ra3apt-cli",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "RA3 .apt file editor CLI — convert between binary .apt and human-readable XML",
|
||||
"main": "./bin/ra3apt.js",
|
||||
"bin": {
|
||||
"ra3apt": "./bin/ra3apt.js"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "node --test test/*.test.cjs",
|
||||
"build:exe": "pkg bin/ra3apt.js --targets node24-win-x64 --output ra3apt-cli.exe"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
# Public CLI regression tests
|
||||
|
||||
Run `npm test` from `ra3apt-cli/` after installing the normal dependencies. All 23 tests invoke the real `bin/ra3apt.js` dispatcher and public expand/build paths in child Node processes. They exercise production XML transformation, assembly parsing, constant pooling and CONST writing. Private conversion functions are not exposed or mocked.
|
||||
|
||||
`fake-cc3tools.cjs` intercepts only the two external converter calls. It transports raw XML in an unmistakable `RA3_CLI_TEST_RAW_XML_ENVELOPE` file and refuses real APT binaries. It performs no flags, color, string, numeric, ID or XML transformation and does not generate CONST files. Its outputs are simulated envelopes, not game resources. Real cc3tools compatibility and game behavior require separate validation.
|
||||
|
||||
- `visual-state.test.cjs`: six tests covering an active null clip-action pointer without creating an array, actual height/RGB/translation edits, unrelated fields, transparent/dim/additive transforms, Move-only placement, active zero ratio, clipdepth and nonidentity matrices.
|
||||
- `supported-bytecode.test.cjs`: three tests covering fractional pushfloat, numeric-looking strings versus integers, and correctly typed pushdata/global CONST references.
|
||||
- `parser-contract.test.cjs`: fourteen tests covering converter-unimplemented character rejection and unlabelled flag bits, quotes, backslashes, commas, ampersands, entity-looking text, LF/CR, inherited-object-key names, UTF-8 byte lengths/pointers, signed type-7 constants, fractional/overflow rejection, export declarations, literal CDATA delimiters and manifest ampersands.
|
||||
- `public-cli-helper.cjs` and `visual-state.raw.xml`: process isolation and independent raw XML/CONST assertions.
|
||||
|
||||
By default artifacts go into a unique child of `ra3apt-cli/.work/tests/`, which is Git-ignored. Set `RA3_CLI_TEST_ARTIFACT_ROOT` to direct all artifacts to a task-specific directory. TEMP/TMP/TMPDIR are bound inside that directory. Set `RA3_APT_CLI_ROOT` to test a different source checkout and `NODE_PATH` to reuse existing dependencies without copying them. Do not commit generated files or dependency copies.
|
||||
|
||||
All 23 tests passed on 2026-10-13. Assertions check edited and unrelated values, actual string/integer types, UTF-8 lengths, pointer alignment and relevant bit patterns; they permit legitimate constant-pool reordering. Fractional pool operands such as `pushconst 1.25` are rejected because existing type 7 is signed32; fractional values use `pushfloat`. UTF-8 tests prove stored bytes, not game font support.
|
||||
|
||||
The real converter emits many float32 fields to six decimal places. Equal expanded XML does not prove bit-exact preservation of the original float32 values. See the CLI README and roundtrip report for this limit and the separate native resource regression.
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
// Test-only transport boundary. This file does not emulate any CLI transformation.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const cp = require('node:child_process');
|
||||
const marker = 'RA3_CLI_TEST_RAW_XML_ENVELOPE\n';
|
||||
|
||||
cp.execFileSync = function fakeConverter(file, args) {
|
||||
const command = path.basename(file).replace(/\.exe$/i, '').toLowerCase();
|
||||
if (!['apt2xml', 'xml2apt'].includes(command) || args.length !== 1) {
|
||||
throw new Error('The test transport permits only apt2xml/xml2apt with one base path');
|
||||
}
|
||||
const base = args[0];
|
||||
if (command === 'apt2xml') {
|
||||
const envelope = fs.readFileSync(base + '.apt', 'utf8');
|
||||
if (!envelope.startsWith(marker)) {
|
||||
throw new Error('The simulated converter refuses real APT binaries');
|
||||
}
|
||||
fs.writeFileSync(base + '.xml', envelope.slice(marker.length));
|
||||
} else {
|
||||
// Keep the .const that production build() wrote; do not supply a fake one.
|
||||
fs.writeFileSync(base + '.apt', marker + fs.readFileSync(base + '.xml', 'utf8'));
|
||||
}
|
||||
return Buffer.alloc(0);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
'use strict';
|
||||
|
||||
// These cover issues independently reproduced by the real-tool audit.
|
||||
const test = require('node:test');
|
||||
const H = require('./public-cli-helper.cjs');
|
||||
|
||||
function rawAction(nodes, exports = '', extra = '') {
|
||||
return `<aptdata>
|
||||
<movieclip id="0" screensizex="1365" screensizey="768" unknown="33">
|
||||
<imports/><exports>${exports}</exports>
|
||||
<frames><frame id="0"><action>
|
||||
${nodes}
|
||||
<noarg action="0"/>
|
||||
</action></frame></frames>
|
||||
</movieclip>
|
||||
${extra}
|
||||
</aptdata>`;
|
||||
}
|
||||
|
||||
test('public build identifies a sound character payload not exported by cc3tools', () => {
|
||||
const work = H.testCase('unsupported-sound');
|
||||
const source = H.expanded(work, rawAction('', '', '<sound code="6" comment="not implemented yet!"/>'));
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.assert.notEqual(rebuilt.result.status, 0);
|
||||
H.assert.match(rebuilt.result.stderr, /cc3tools did not export the sound character payload \(code 6\)/);
|
||||
});
|
||||
|
||||
test('public roundtrip retains an unlabelled button-action flag bit', () => {
|
||||
const work = H.testCase('opaque-button-flag');
|
||||
const button = '<button id="1"><buttonrecords/><buttonactions><buttonaction flags="64" keycode="0">\n' +
|
||||
'<noarg action="0"/>\n</buttonaction></buttonactions></button>';
|
||||
const source = H.expanded(work, rawAction('', '', button));
|
||||
H.assert.equal(H.element(source.xml, 'buttonaction', 'keycode', 0).flags, '0x40');
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
H.assert.equal(Number(H.element(rebuilt.raw, 'buttonaction', 'keycode', 0).flags), 64);
|
||||
});
|
||||
|
||||
test('public action-string roundtrip retains quotes, comma, backslash and &Outline literally', () => {
|
||||
const work = H.testCase('quoted-action');
|
||||
const literal = 'say "Q", &Outline <tag> \\literal';
|
||||
const source = H.expanded(work, rawAction('<string action="161" str="say "Q", &Outline <tag> \\literal"/>'));
|
||||
H.assert.match(source.xml, /<!\[CDATA\[/);
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
H.assert.equal(H.element(rebuilt.raw, 'string', 'action', 161).str, literal);
|
||||
});
|
||||
|
||||
test('public action-string roundtrip decodes XML once and preserves literal entity-looking text', () => {
|
||||
const work = H.testCase('entity-literal');
|
||||
const source = H.expanded(work, rawAction('<string action="161" str="&quot; &amp; &Outline"/>'));
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
H.assert.equal(H.element(rebuilt.raw, 'string', 'action', 161).str, '" & &Outline');
|
||||
});
|
||||
|
||||
test('public action-string roundtrip preserves a literal CDATA closing delimiter', () => {
|
||||
const work = H.testCase('cdata-delimiter');
|
||||
const source = H.expanded(work, rawAction('<string action="161" str="]]>"/>'));
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
H.assert.equal(H.element(rebuilt.raw, 'string', 'action', 161).str, ']]>');
|
||||
});
|
||||
|
||||
test('public action-string roundtrip retains actual LF and CR rather than replacing them with slash letters', () => {
|
||||
const work = H.testCase('newlines');
|
||||
const source = H.expanded(work, rawAction('<string action="161" str="before after last"/>'));
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
H.assert.equal(H.element(rebuilt.raw, 'string', 'action', 161).str, 'before\nafter\rlast');
|
||||
});
|
||||
|
||||
test('public geturl roundtrip does not split a comma inside an escaped quoted argument', () => {
|
||||
const work = H.testCase('geturl-comma');
|
||||
const source = H.expanded(work, rawAction('<geturl action="131" str1="x"y,z?a&b" str2="target"room"/>'));
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
const url = H.element(rebuilt.raw, 'geturl', 'action', 131);
|
||||
H.assert.equal(url.str1, 'x"y,z?a&b');
|
||||
H.assert.equal(url.str2, 'target"room');
|
||||
});
|
||||
|
||||
test('public constant pooling accepts constructor/__proto__/toString as ordinary string values', () => {
|
||||
const work = H.testCase('constant-object-keys');
|
||||
const source = H.expanded(work, rawAction(`<constantpool action="136">
|
||||
<constant val="0" str="constructor"/>
|
||||
<constant val="1" str="__proto__"/>
|
||||
<constant val="2" str="toString"/>
|
||||
</constantpool>
|
||||
<byte action="162" val="0"/>
|
||||
<byte action="162" val="1"/>
|
||||
<byte action="162" val="2"/>
|
||||
<byte action="162" val="0"/>`));
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
H.assert.deepEqual(H.constRecords(rebuilt.output).map(record => record.value).sort(), ['__proto__', 'constructor', 'toString']);
|
||||
for (const value of ['constructor', '__proto__', 'toString']) {
|
||||
H.assert.equal(H.element(rebuilt.raw, 'constant', 'str', value).str, value);
|
||||
}
|
||||
});
|
||||
|
||||
test('public build writes Chinese string constants with UTF-8 byte lengths and distinct aligned pointers', () => {
|
||||
const work = H.testCase('utf8-pool');
|
||||
const source = H.expanded(work, rawAction(`<constantpool action="136">
|
||||
<constant val="0" str="网络标题"/>
|
||||
<constant val="1" str="ASCII&Outline"/>
|
||||
</constantpool>
|
||||
<byte action="162" val="0"/>
|
||||
<byte action="162" val="1"/>`));
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
const records = H.constRecords(rebuilt.output);
|
||||
H.assert.deepEqual(records.map(record => record.value).sort(), ['ASCII&Outline', '网络标题']);
|
||||
for (const record of records) H.assert.equal(record.offset % 4, 0);
|
||||
const byOffset = records.slice().sort((a, b) => a.offset - b.offset);
|
||||
H.assert.ok(byOffset[1].offset >= byOffset[0].offset + Buffer.byteLength(byOffset[0].value, 'utf8') + 1);
|
||||
});
|
||||
|
||||
test('public roundtrip writes the actual type-7 -1 sentinel as 0xffffffff without RangeError', () => {
|
||||
const work = H.testCase('signed-integer-pool');
|
||||
const source = H.expanded(work, rawAction(`<constantpool action="136">
|
||||
<constant val="0" num="-1"/>
|
||||
</constantpool>
|
||||
<byte action="162" val="0"/>`));
|
||||
H.assert.match(source.xml, /pushconst -1/);
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
H.assert.deepEqual(H.constRecords(rebuilt.output), [{ type: 7, bits: 0xffffffff, value: -1 }]);
|
||||
H.assert.equal(H.element(rebuilt.raw, 'constant', 'num', '-1').num, '-1');
|
||||
});
|
||||
|
||||
test('public build explicitly rejects fractional constant-pool values instead of silently truncating', () => {
|
||||
const work = H.testCase('fractional-pool');
|
||||
const human = `<aptdata><movieclip screensizex="1365" screensizey="768" unknown="33">
|
||||
<imports/><exports/><frames><frame id="0"><action><![CDATA[
|
||||
pushconst 1.25
|
||||
actionEnd
|
||||
]]></action></frame></frames>
|
||||
</movieclip></aptdata>`;
|
||||
const rebuilt = H.build(work, human);
|
||||
H.assert.notEqual(rebuilt.result.status, 0);
|
||||
H.assert.match(rebuilt.result.stderr, /constant|non.?integer|fraction|unsupported|numeric/i);
|
||||
});
|
||||
|
||||
test('public build rejects an integer that would wrap into a different signed type-7 value', () => {
|
||||
const work = H.testCase('integer-pool-overflow');
|
||||
const human = `<aptdata><movieclip screensizex="1365" screensizey="768" unknown="33">
|
||||
<imports/><exports/><frames><frame id="0"><action><![CDATA[
|
||||
pushconst 2147483648
|
||||
actionEnd
|
||||
]]></action></frame></frames>
|
||||
</movieclip></aptdata>`;
|
||||
const rebuilt = H.build(work, human);
|
||||
H.assert.notEqual(rebuilt.result.status, 0);
|
||||
H.assert.match(rebuilt.result.stderr, /signed 32-bit integers/);
|
||||
H.assert.equal(H.fs.existsSync(rebuilt.output), false);
|
||||
});
|
||||
|
||||
test('public texture-manifest roundtrip decodes and encodes an ampersand path once', () => {
|
||||
const work = H.testCase('texture-entity');
|
||||
const input = H.writeRaw(work, rawAction('<noarg action="4"/>', '', '<image id="1" image="1"/>'));
|
||||
H.fs.writeFileSync(input.replace(/\.apt$/, '.xml'), '<AssetDeclaration><Texture id="apt_fixture_1" File="art/A&B.tga"/></AssetDeclaration>');
|
||||
H.fs.writeFileSync(input.replace(/\.apt$/, '.dat'), '1->1\n');
|
||||
const before = H.path.join(work.directory, 'image_edit.xml');
|
||||
H.succeeded(H.cli(work, 'expand', input, before));
|
||||
const human = H.fs.readFileSync(before, 'utf8');
|
||||
H.assert.equal(H.element(human, 'image', 'id', 1).image, 'art/A&B.tga');
|
||||
const rebuilt = H.build(work, human);
|
||||
H.succeeded(rebuilt.result);
|
||||
const after = H.path.join(work.directory, 'after_edit.xml');
|
||||
H.succeeded(H.cli(work, 'expand', rebuilt.output, after));
|
||||
H.assert.equal(H.element(H.fs.readFileSync(after, 'utf8'), 'image', 'id', 1).image, 'art/A&B.tga');
|
||||
});
|
||||
|
||||
test('public expand/build preserves all original export names and repeated declarations in order', () => {
|
||||
const work = H.testCase('export-object-keys');
|
||||
const exports = '<export character="1" name="constructor"/><export character="1" name="toString"/>' +
|
||||
'<export character="1" name="repeated"/><export character="1" name="repeated"/>';
|
||||
const source = H.expanded(work, rawAction('<noarg action="7"/>', exports, '<empty id="1"/>'));
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
for (const xml of [source.xml, rebuilt.raw]) {
|
||||
H.assert.deepEqual(Array.from(xml.matchAll(/<export\b[^>]*>/g), match => H.attributes(match[0]).name),
|
||||
['constructor', 'toString', 'repeated', 'repeated']);
|
||||
}
|
||||
});
|
||||
@@ -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 = { '&': '&', '"': '"', ''': "'", '<': '<', '>': '>' };
|
||||
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 };
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const H = require('./public-cli-helper.cjs');
|
||||
|
||||
function rawAction(nodes) {
|
||||
return `<aptdata>
|
||||
<movieclip id="0" screensizex="1365" screensizey="768" unknown="33">
|
||||
<imports/><exports/>
|
||||
<frames><frame id="0"><action>
|
||||
${nodes}
|
||||
<noarg action="0"/>
|
||||
</action></frame></frames>
|
||||
</movieclip>
|
||||
</aptdata>`;
|
||||
}
|
||||
|
||||
test('public expand/build preserves an edited supported pushfloat without integer truncation', () => {
|
||||
const work = H.testCase('pushfloat');
|
||||
const source = H.expanded(work, rawAction('<float action="180" val="85.25"/>'));
|
||||
H.assert.match(source.xml, /pushfloat 85\.25/);
|
||||
const edited = source.xml.replace('pushfloat 85.25', 'pushfloat 24.5');
|
||||
H.assert.notEqual(edited, source.xml);
|
||||
const rebuilt = H.build(work, edited);
|
||||
H.succeeded(rebuilt.result);
|
||||
H.assert.equal(Number(H.element(rebuilt.raw, 'float', 'action', 180).val), 24.5);
|
||||
});
|
||||
|
||||
test('public expand/build distinguishes a numeric-looking quoted string from an integer pool value', () => {
|
||||
const work = H.testCase('quoted-numeric');
|
||||
const source = H.expanded(work, rawAction(`<constantpool action="136">
|
||||
<constant val="0" str="007"/>
|
||||
<constant val="1" num="7"/>
|
||||
</constantpool>
|
||||
<byte action="162" val="0"/>
|
||||
<byte action="162" val="1"/>`));
|
||||
H.assert.match(source.xml, /pushconst "007"/);
|
||||
H.assert.match(source.xml, /pushconst 7(?:\s|<)/);
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
H.assert.equal(H.element(rebuilt.raw, 'constant', 'str', '007').str, '007');
|
||||
H.assert.equal(H.element(rebuilt.raw, 'constant', 'num', '7').num, '7');
|
||||
// This .const was generated by production build(), not by the fake converter.
|
||||
const table = H.fs.readFileSync(rebuilt.output.replace(/\.apt$/, '.const'));
|
||||
H.assert.equal(table.readUInt32LE(0x18), 2);
|
||||
const values = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const offset = 0x20 + i * 8;
|
||||
const type = table.readUInt32LE(offset);
|
||||
const value = table.readUInt32LE(offset + 4);
|
||||
values.push(type === 1 ? { type: 'string', value: table.subarray(value, table.indexOf(0, value)).toString('ascii') } :
|
||||
{ type: 'integer', value });
|
||||
}
|
||||
H.assert.deepEqual(values, [{ type: 'string', value: '007' }, { type: 'integer', value: 7 }]);
|
||||
});
|
||||
|
||||
test('public pushdata build retains quoted "007" and integer 7 as data nodes and correctly typed CONST values', () => {
|
||||
const work = H.testCase('pushdata-types');
|
||||
const source = H.expanded(work, rawAction(`<pushdata action="150">
|
||||
<data val="0" str="007"/>
|
||||
<data val="1" num="7"/>
|
||||
</pushdata>`));
|
||||
H.assert.match(source.xml, /pushdata "007", 7/);
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
const body = rebuilt.raw.match(/<pushdata\b[^>]*>([\s\S]*?)<\/pushdata>/);
|
||||
H.assert.ok(body);
|
||||
const children = Array.from(body[1].matchAll(/<(data|constant)\b[^>]*>/g), match =>
|
||||
({ tag: match[1], attrs: H.attributes(match[0]) }));
|
||||
H.assert.equal(children.length, 2);
|
||||
H.assert.deepEqual(children.map(child => child.tag), ['data', 'data'],
|
||||
'xml2apt expects data children; constant children previously crashed the native converter');
|
||||
H.assert.equal(children[0].attrs.str, '007');
|
||||
H.assert.equal(children[0].attrs.num, undefined);
|
||||
H.assert.equal(Number(children[1].attrs.num), 7);
|
||||
H.assert.equal(children[1].attrs.str, undefined);
|
||||
const records = H.constRecords(rebuilt.output);
|
||||
H.assert.deepEqual(records.map(record => ({ type: record.type, value: record.value })),
|
||||
[{ type: 1, value: '007' }, { type: 7, value: 7 }]);
|
||||
H.assert.equal(Number(children[0].attrs.val), 0);
|
||||
H.assert.equal(Number(children[1].attrs.val), 1);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<aptdata>
|
||||
<movieclip id="0" screensizex="1365" screensizey="768" unknown="33">
|
||||
<imports/><exports/>
|
||||
<frames>
|
||||
<frame id="0">
|
||||
<action>
|
||||
<noarg action="7"/>
|
||||
<noarg action="0"/>
|
||||
</action>
|
||||
<placeobject flags="126" depth="1" character="2" rotm00="0.85" rotm01="0.125" rotm10="-0.25" rotm11="1.25" tx="12.25" ty="-3.5" red="37" green="73" blue="211" alpha="0" unknown="0" ratio="0" clipdepth="8"><poname name="row"/></placeobject>
|
||||
<placeobject flags="46" depth="2" character="2" rotm00="1" rotm01="0" rotm10="0" rotm11="1" tx="0" ty="22.5" red="201" green="17" blue="61" alpha="127" unknown="67305985"><poname name="dimmed"/></placeobject>
|
||||
</frame>
|
||||
<frame id="1">
|
||||
<!-- cc3tools emits default expanded fields even when flags omit them. -->
|
||||
<placeobject flags="1" depth="1" character="-1" rotm00="1" rotm01="0" rotm10="0" rotm11="1" tx="0" ty="0" red="255" green="255" blue="255" alpha="255" unknown="0" ratio="0" clipdepth="-1"/>
|
||||
</frame>
|
||||
<frame id="2">
|
||||
<placeobject flags="81" depth="1" character="-1" rotm00="1" rotm01="0" rotm10="0" rotm11="1" tx="0" ty="0" red="255" green="255" blue="255" alpha="255" unknown="0" ratio="0" clipdepth="0"/>
|
||||
</frame>
|
||||
</frames>
|
||||
</movieclip>
|
||||
<font id="1" name="Lucida Sans"/>
|
||||
<edittext id="2" top="-2" left="-2" bottom="26" right="317" font="1" alignment="0" red="17" green="83" blue="201" alpha="137" height="18" readonly="1" multiline="0" wordwrap="0"><ettext text="A & B "menu""/><etvar variable=""/></edittext>
|
||||
</aptdata>
|
||||
@@ -0,0 +1,99 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const H = require('./public-cli-helper.cjs');
|
||||
const fixture = H.fs.readFileSync(H.path.join(__dirname, 'visual-state.raw.xml'), 'utf8');
|
||||
|
||||
test('public roundtrip retains an active null clip-action pointer without adding an array', () => {
|
||||
const work = H.testCase('empty-clip-actions');
|
||||
const raw = fixture.replace('flags="126"', 'flags="254"');
|
||||
H.assert.notEqual(raw, fixture);
|
||||
const source = H.expanded(work, raw);
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
H.assert.equal(Number(H.element(H.frame(rebuilt.raw, 0), 'placeobject', 'depth', 1).flags), 254);
|
||||
H.assert.doesNotMatch(source.xml, /<clipactions\b/);
|
||||
H.assert.doesNotMatch(rebuilt.raw, /<clipactions\b/);
|
||||
});
|
||||
|
||||
test('public expand/build applies a height edit while retaining text RGBA and other text fields', () => {
|
||||
const work = H.testCase('text-height');
|
||||
const source = H.expanded(work, fixture);
|
||||
H.assert.deepEqual(['red', 'green', 'blue', 'alpha'].map(k => H.element(source.xml, 'edittext', 'id', 2)[k]),
|
||||
['17', '83', '201', '137']);
|
||||
const edited = source.xml.replace('height="18"', 'height="16"');
|
||||
H.assert.notEqual(edited, source.xml, 'the requested edit must actually alter the XML');
|
||||
const rebuilt = H.build(work, edited);
|
||||
H.succeeded(rebuilt.result);
|
||||
const text = H.element(rebuilt.raw, 'edittext', 'id', 2);
|
||||
H.assert.equal(Number(text.height), 16);
|
||||
for (const [key, expected] of Object.entries({ red: '17', green: '83', blue: '201', alpha: '137',
|
||||
top: '-2', left: '-2', bottom: '26', right: '317', alignment: '0', readonly: '1', multiline: '0', wordwrap: '0' })) {
|
||||
H.assert.equal(text[key], expected, `${key} is unrelated to the height edit`);
|
||||
}
|
||||
H.assert.equal(H.element(rebuilt.raw, 'movieclip', 'id', 0).unknown, '33');
|
||||
H.assert.equal(H.element(rebuilt.raw, 'ettext', 'text', 'A & B "menu"').text, 'A & B "menu"');
|
||||
});
|
||||
|
||||
test('public build honors deliberately edited text RGB in the same contract as expand', () => {
|
||||
const work = H.testCase('text-color-edit');
|
||||
const source = H.expanded(work, fixture);
|
||||
const edited = source.xml.replace(/(<edittext\b[^>]*?)red="17" green="83" blue="201"/,
|
||||
'$1red="7" green="19" blue="233"');
|
||||
H.assert.notEqual(edited, source.xml);
|
||||
const rebuilt = H.build(work, edited);
|
||||
H.succeeded(rebuilt.result);
|
||||
const text = H.element(rebuilt.raw, 'edittext', 'id', 2);
|
||||
H.assert.deepEqual(['red', 'green', 'blue', 'alpha'].map(k => text[k]), ['7', '19', '233', '137']);
|
||||
H.assert.equal(Number(text.height), 18);
|
||||
});
|
||||
|
||||
test('public roundtrip retains transparent/dim color transforms and additive color values', () => {
|
||||
const work = H.testCase('placement-color');
|
||||
const source = H.expanded(work, fixture);
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
for (const depth of [1, 2]) {
|
||||
const before = H.element(H.frame(fixture, 0), 'placeobject', 'depth', depth);
|
||||
const after = H.element(H.frame(rebuilt.raw, 0), 'placeobject', 'depth', depth);
|
||||
H.assert.equal(Number(after.flags), Number(before.flags));
|
||||
H.assert.ok(Number(after.flags) & 8, 'HasColorTransform is semantically active');
|
||||
for (const key of ['red', 'green', 'blue', 'alpha', 'unknown']) {
|
||||
H.assert.equal(after[key], before[key], `depth ${depth}: preserve ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('public roundtrip retains Move-only and active zero ratio/clipdepth without inventing matrix/color state', () => {
|
||||
const work = H.testCase('move-flags');
|
||||
const source = H.expanded(work, fixture);
|
||||
const move = H.element(H.frame(source.xml, 1), 'placeobject', 'depth', 1);
|
||||
H.assert.equal(move.flags, 'Move');
|
||||
for (const key of ['matrix', 'rotm00', 'rotm01', 'rotm10', 'rotm11', 'tx', 'ty',
|
||||
'rgbamul', 'rgbaadd', 'red', 'green', 'blue', 'alpha', 'unknown']) {
|
||||
H.assert.equal(move[key], undefined, `inactive raw dump field ${key} must not become an authored edit`);
|
||||
}
|
||||
const rebuilt = H.build(work, source.xml);
|
||||
H.succeeded(rebuilt.result);
|
||||
H.assert.equal(Number(H.element(H.frame(rebuilt.raw, 1), 'placeobject', 'depth', 1).flags), 1);
|
||||
const zero = H.element(H.frame(rebuilt.raw, 2), 'placeobject', 'depth', 1);
|
||||
H.assert.equal(Number(zero.flags), 81);
|
||||
H.assert.equal(Number(zero.ratio), 0);
|
||||
H.assert.equal(Number(zero.clipdepth), 0);
|
||||
});
|
||||
|
||||
test('public build applies one translation edit while retaining the nonidentity matrix, ratio and clipping contract', () => {
|
||||
const work = H.testCase('matrix-edit');
|
||||
const source = H.expanded(work, fixture);
|
||||
const edited = source.xml.replace('tx="12.25"', 'tx="32.25"');
|
||||
H.assert.notEqual(edited, source.xml);
|
||||
const rebuilt = H.build(work, edited);
|
||||
H.succeeded(rebuilt.result);
|
||||
const placement = H.element(H.frame(rebuilt.raw, 0), 'placeobject', 'depth', 1);
|
||||
H.assert.equal(Number(placement.tx), 32.25);
|
||||
H.assert.equal(Number(placement.flags), 126);
|
||||
H.assert.deepEqual(['rotm00', 'rotm01', 'rotm10', 'rotm11', 'ty'].map(k => Number(placement[k])),
|
||||
[0.85, 0.125, -0.25, 1.25, -3.5]);
|
||||
H.assert.equal(Number(placement.ratio), 0);
|
||||
H.assert.equal(Number(placement.clipdepth), 8);
|
||||
});
|
||||
Reference in New Issue
Block a user