13 KiB
RA3 Apt CLI — 工作计划
将 ra3aptxmleditor 的 .apt ↔ 人类可读 XML 双向转换流程 CLI 化。 基于
ra3apt-cli/目录下的 Node.js 项目。
0. 项目渊源
本 CLI 工具基于两个上游项目:
- jonwil/cc3tools — 提供
apt2xml.exe(.apt → raw XML)和xml2apt.exe(raw XML → .apt),是本工具的二进制转换引擎。 - utunnels/ra3aptxmleditor — 基于 nw.js 的 RA3 .apt 图形化编辑器,其
preprocess.html中的xmladdhint()(raw XML → 人类可读 XML)和xmlcleanhint()(人类可读 XML → raw XML)是本 CLI 核心逻辑的直接来源。
本 CLI 将 ra3aptxmleditor 的编辑流程从 nw.js 桌面 GUI 迁移到命令行环境,使用 cheerio 替代 jQuery,保持与原始工具的行为一致性。
1. 总体决策记录
1.1 项目定位
- CLI 工具行为与现有 nw.js GUI 完全一致,包括读写 manifest XML、.dat、.ru 文件
- 两阶段转换模式:
ra3apt-cli expand(apt → 人类可读 XML) 和ra3apt-cli build(人类可读 XML → apt) - 仅 Windows(cc3tools 提供的是 Windows .exe)
1.2 输出文件命名
| 操作 | 默认输出 | 说明 |
|---|---|---|
expand |
<name>_edit.xml |
人类可读 XML |
build |
<name>.apt + <name>.const |
二进制 .apt + 常量表 |
用户可自由指定 -o 覆盖默认名。
1.3 文件冲突策略
人类可读 XML 与 manifest XML 不会冲突,因为:
- manifest XML 是源目录中的
[name].xml(AssetDeclaration 格式,由 RA3 Mod SDK 管理) - apt raw XML 是
%TEMP%/[name].xml(临时文件,用户不可见) - 人类可读 XML 默认是
[name]_edit.xml(不同文件名) - CLI 写回 manifest XML 的行为与现有 GUI 完全一致(第 3089 行)
.ru几何数据内嵌在人类可读 XML 的<shape><ru>元素中,build 时不再依赖源目录中的.ru文件
1.4 依赖库
| 库 | 用途 | 选择原因 |
|---|---|---|
cheerio@1.0.0-rc.12 |
DOM 操作(替代 jQuery) | 99% API 兼容,已验证全部 15 类用法 |
commander |
CLI 框架 | 最流行的 Node.js CLI 框架 |
1.5 CDATA 方案(修订)
原始代码用 CDATASection 包裹 action 汇编代码。CLI 改用先 text node + 后处理 CDATA 包装的方式。
| 方向 | 原始 | CLI |
|---|---|---|
| expand (写入) | xmldoc.createCDATASection(text) |
v.empty().text(astr) cheerio 序列化后,用正则将 action/initaction/clipaction 的内容替换为 CDATA |
| build (回读) | $(el).text() 自动解码 CDATA |
cheerio 在 xmlMode 下自动将 CDATA 视为 text node,.text() 正常提取 |
关键细节:
- cheerio 不直接支持创建 CDATA 节点,所以用正则后处理实现
- 正则必须匹配带属性的标签(如
<clipaction flags="Construct">) clipactions(复数)不会被误匹配,因为正则要求标签名完整后跟空格或>&双编码问题通过decodeAttrEntities()在actiontostring中解决
1.6 错误处理
- 原始代码中的
alert()全部改为throw new Error(msg) - CLI 入口层
catch后打印到 stderr 并以 exit code 1 退出 - 覆盖全部 15 处 alert(重复 ID、跳转目标未找到、语法错误、超出 255 等)
- 仅
console.log/console.warn的诊断信息保留(非致命,不影响流程)
1.7 浏览器特有 API 等价替换表
| 浏览器/nw.js API | CLI 替换 | 出现次数 |
|---|---|---|
$.parseXML(xmlStr) |
cheerio.load(xmlStr, { xmlMode: true }) |
4 |
new XMLSerializer().serializeToString(doc) |
$.xml() |
2 |
xmldoc.createCDATASection(text) |
正则后处理替换:/<tag>([\s\S]*?)<\/tag>/ → <tag><![CDATA[$1]]></tag> |
1 |
xmldoc.createElement(tag) |
$('<' + tag + '>', $) |
3 |
$(el).attr(name) / .attr(name, val) |
不变 | ~60 |
alert(msg) |
throw new Error(msg) |
~15 |
escape(t) |
encodeURIComponent(t) |
1 |
el.outerHTML |
$.html(el) |
3 |
el.previousSibling |
el.prev(cheerio 节点) |
2 |
el.textContent |
el.data(文本节点) |
1 |
el.cloneNode() |
$.clone(el)[0] 或 cheerio 原生 clone |
1 |
editor.getValue() / editor.setValue() |
fs.readFileSync() / fs.writeFileSync() |
GUI 专属 |
| nw.js / CodeMirror / Canvas / jQuery UI | 全部省略 | GUI 专属 |
2. 关键代码定位(preprocess.html)
| 模块 | 行号 | 说明 |
|---|---|---|
常量表 — ActionNames |
1484-1619 | opcode 名称表 |
常量表 — ActionAligns |
1627-1647 | 哪些 action 需要对齐 |
常量表 — ActionSizes |
1648-1835 | 每个 action 的固定长度 |
| 常量表 — flags 枚举 | 250-354 | PlaceObjectFlags / ClipEventFlags / ButtonRecordFlags / ButtonActionFlags / FunctionPreloadFlags / ButtonInput |
辅助函数 — el() / bad() |
2283-2288 | 元素创建 |
辅助函数 — endian() / valuetoflags() / flagstovalue() |
356-396 | 字节序/flag 转换 |
辅助函数 — encodeXML() / mkstr() |
1856-1866 | 字符串编码 |
| 辅助函数 — 对齐计算 | 1968-2001 | alnsize() / calcaln() / thisaln() / nextaln() / copyindent() / indentstr() |
核心 — xmladdhint() (expand) |
2023-2280 | raw XML → 人类可读 XML |
核心 — actiontostring() |
1885-1967 | action 节点 → 文本汇编语句 |
核心 — xmlcleanhint() (build) |
2597-3095 | 人类可读 XML → raw XML |
核心 — stringtoaction() |
2290-2582 | 文本汇编语句 → action 节点 |
| 核心 — .const 二进制写入 | 2741-2766 | 常量表二进制序列化 |
| 核心 — manifest / .dat 生成 | 2964-3092 | 写回 manifest XML、.dat、.ru |
核心 — loadapt() (打开流程) |
488-508 | apt → raw XML 的编排 |
3. 进度追踪
当前状态:✅ 全部完成
Phase 1 — 项目脚手架与常量提取 ✅
- 创建
ra3apt-cli/项目结构(已完成package.json初始化 +cheerio安装) - 安装
commander依赖 - 提取常量表到
lib/constants.js:ActionNames/ActionNames2(新旧两套 opcode 名称)ActionSizes/ActionAlignsPlaceObjectFlags/ClipEventFlags/ButtonRecordFlags/ButtonActionFlags/FunctionPreloadFlags/ButtonInput
- 创建 CLI 入口文件
bin/ra3apt.js,挂载expand和build子命令
Phase 2 — 工具函数层 ✅
- 提取通用工具函数到
lib/utils.js:endian()/valuetoflags()/flagstovalue()encodeXML()/mkstr()/el()/bad()/makelabel()alnsize()/calcaln()/thisaln()/nextaln()/copyindent()/indentstr()rgba()/getrgba()/cleanrgba()/getmatrix()/cleanmatrix()
- 适配浏览器 DOM 原生属性访问 → cheerio 等价写法
- 编写
expand/builder两流程共享的临时目录管理、exe 调用、文件复制逻辑
Phase 3 — 实现 ra3apt-cli expand ✅
- 实现
expand()流程:复制 .apt + .const → 运行apt2xml.exe→ 读取 manifest XML + .dat - 移植
xmladdhint()全部逻辑:- 常量展开
- 跳转 label 解析
- action 汇编化 + text node 写入
- flags 符号化
- image / shape 纹理解析
- 编辑者友好清理
- 移植
actiontostring()全部九种 action 分支 - 序列化输出人类可读 XML
Phase 4 — 实现 ra3apt-cli build ✅
- 移植
xmlcleanhint()全部逻辑:- 文本汇编解析 +
stringtoaction() - 常量池重构建 + .const 二进制写入(
BufferAPI,Node.js 原生) - character ID 重编号
- 跳转偏移量解析
- function size 计算
- frame 编号
- flags 数值化
- placeobject flags 构建
- shape/ru/image 处理 + manifest / .dat / .ru 生成
- 矩阵/颜色属性拆解和清理
- 文本汇编解析 +
- 运行
xml2apt.exe生成 .apt + .const - 写回 manifest XML、.dat、.ru 文件
Phase 5 — 端到端测试与文档 ✅
- 从
assets/获取 3 组测试数据(ig_HUD、fem_m_gameSetup、main_mouse) - 验证完整回路:
.apt→ expand →_edit.xml→ build →.apt→ apt2xml → raw XML 对比 - 全部 3 组测试通过,raw XML 完全一致
- 清理测试临时文件
- 编写 README 与 CLI 帮助文本
Phase 6 — Bug 修复与完善 ✅
- Phase 6.1 —
parseArgs引号保留:parseArgs()返回的参数字符串两端保留引号,导致str属性带多余引号 - Phase 6.2 — 标签名不匹配:build 产生
data/noarg标签但 cc3tools 需要byte/short/float/long - Phase 6.3 — placeobject flag 计算缺失 rotm00→matrix 转换:人类可读 XML 使用
rotm00格式,但 flag 计算需matrix格式 - Phase 6.4 — label 正则冒号:
^([\w]+):返回lxnx0:(带冒号),导致[mylabel=lxnx0:]找不到目标 - Phase 6.5 — CDATA 正则误匹配 clipactions:原正则
([^>]*)吞掉clipactions的s,修复为(\s[^>]*)? - Phase 6.6 —
&双编码:decodeEntities: false读取 raw XML 时&保持原样,cheerio 序列化再编码 → 需要decodeAttrEntities()手动解码 - Phase 6.7 —
pushconst "1"被误判为数字:parseInt("1")= 1,isNaN(1)= false → 被当作数字常量。修复:检查原始argStr是否以引号开头 - Phase 6.8 — .const 文件整型标识符错误:
writeUInt32LE(2)应为writeUInt32LE(7),并加console.warn警告 - Phase 6.9 — build 自动创建输出目录:添加
fs.mkdirSync+{ recursive: true }确保输出目录和 .ru 子目录自动创建
4. 注意事项
4.1 cheerio 使用要点
- 始终使用
{ xmlMode: true, decodeEntities: false }加载 XML - 元素创建:
$('<' + tagName + '>')— 创建的元素不会自动加入当前文档,需要手动.append()/.prepend()等 .text(text)设置文本时,<&等字符自动 entity 编码,.text()回读自动解码.sort(fn)可用在 cheerio 集合上(继承 Array.prototype.sort)$(el).is(selector)只接受 CSS 选择器字符串,不支持传入 jQuery 对象(GUI 预览代码用$(f).is(curnode)但那是 GUI 专属,不需要移植)
4.2 二进制 .const 文件写入
第 237-257 行的 Buffer.alloc / writeUInt32LE / asciiWrite / fs.openSync / fs.writeSync / fs.closeSync 已经是标准的 Node.js API,不需要额外改动。注意:
- 整型常量标识符从原代码的
writeUInt32LE(2)修正为writeUInt32LE(7)(7 才是正确的整型标识) - 当有整数写入 consttable 时会输出
console.warn警告
4.3 manifest XML 写回策略
- build 操作会写回
filedir/[name].xml(manifest XML),行为与 GUI 第 3089 行完全一致 - 同时也写回
.dat、.ru文件 - CLI 不会写回
_edit.xml本身,只消费它
4.4 临时目录
- 遵循原代码方式(第 515 行):
fs.mkdtempSync(os.tmpdir() + path.sep) + path.sep - apt raw XML (
[name].xml) 生成在这里,处理完后清理 - 可通过
--keep-temp标志保留临时目录用于调试
4.5 cc3tools 路径
- 默认相对路径:
cc3tools/(从启动位置出发,即与ra3apt-cli/同级的工作区目录或由用户指定) - 可通过
--tools-path参数覆盖
4.6 pushconst 字符串/数字区分
pushconst "1"(带引号)应作为字符串常量处理,pushconst 1(无引号)作为数字常量。区分逻辑:
// 检查原始 argStr 是否以引号开头
if (argStr && argStr[0] === '"') {
a.attr('str', args[0] || ''); // 字符串
} else {
const numVal = parseInt(args[0], 10);
if (isNaN(numVal) || args[0] === '') {
a.attr('str', args[0] || ''); // 不是数字 → 字符串
} else {
a.attr('num', numVal); // 是数字
}
}
4.7 decodeAttrEntities() — 防止 & 双编码
expand 以 decodeEntities: false 加载 raw XML,属性值中的 & 保持原样。但 cheerio 序列化时会将文本中的 & 再编码为 &,导致双编码。修复:在 actiontostring() 中通过 decodeAttrEntities() 手动解码实体后再输出。
function decodeAttrEntities(s) {
if (typeof s !== 'string') return s;
return s.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'");
}
4.8 自动创建输出目录
build 命令会在写入输出文件前自动创建目录:
fs.mkdirSync(outDir, { recursive: true })— 确保.apt/.const/.xml/.dat的输出目录存在fs.mkdirSync(path.dirname(rup), { recursive: true })— 确保.ru文件所在的子目录(如geometry/)存在
用户无需手动创建输出目录,build 会递归创建所有缺失的父目录。
4.9 错误时 return false 的处理
原始代码中 xmlcleanhint() 在出错时 return false,调用方据此判断。CLI 版本改为 throw new Error(),不再需要返回值检查。
原始模式(第 430 行):
if(xmlcleanhint()){
// 继续保存
}else{
// 保存失败
}
CLI 模式:
try {
xmlcleanhint(...); // 出错时 throw
// 继续保存
} catch(err) {
console.error(err.message);
process.exit(1);
}