在游戏外播放《X4:基石》的真实 NPC 动画,用来检查人物替换 mod 的动作是否正常。
做 X4 人物替换 mod 时,每改一版都得进游戏、跑到 NPC 面前、盯着看几秒——慢且看不出细节。
这个工具直接读游戏里的 .xsm 动画和 .xac 网格,在本地把你的 mod 资产和 vanilla
并排播放同一条动画,关节撕裂、四肢弯错方向、猫步、手/脖子错位这些一眼就能看到。
左边两格是 mod 里的两套模型(同一套骨架、同一条动画、同一时刻),右边是 vanilla 基准。 mod 里有多套时全部列出来,点一下切换,多选就分屏并排。
| 方式 | 怎么做 | 说明 |
|---|---|---|
| 快捷方式(最稳) | python tools/make_shortcut.py --desktop,然后双击生成的快捷方式 |
直接指向 pythonw.exe,不经过任何文件关联,也不弹控制台 |
| 批处理 | 双击 run_studio.bat |
想看到日志和报错时用 |
| 命令行 | python tools/studio.py |
图形界面,终端用户 |
| 命令行(无界面) | python tools/viewer.py --mod <目录> |
脚本化、批处理、出图 |
为什么推荐生成快捷方式:
.py的文件关联在很多机器上被 Visual Studio 接管, 双击会打开 IDE 而不是运行脚本;.bat则容易被别的软件抢关联,而且必须是 CRLF 行尾(LF 行尾的 bat 会被 cmd 解析错位,比如把studio.py拆成stud+o.py)。 快捷方式绕开这一切。
X4 的 NPC 是换网格、留骨架:libraries/character_components.xml 里的共享
component(比如 character_argon_female_01)持有整套 Biped 骨架和 180 多条动画,
macro 只挑 head / torso / props 三个网格槽位。所以:
- 你的 mod 网格用的就是游戏里那套骨架,游戏的动画可以直接拿来驱动它;
- 检查"动作对不对"不需要启动游戏,只要把同一条
.xsm套上去播放。
本仓库自带 .xsm 格式的解析实现(逆向所得,见下),不依赖 3ds Max 或官方插件。
pip install -r requirements.txt # numpy / pillow / pyglet
cp config.example.json config.json # 按需改 game_dir(也可用环境变量 X4_GAME_DIR)
python tools/make_shortcut.py --desktop # 生成快捷方式,之后双击即可双击生成的快捷方式,或者:
python tools/studio.py左边是控制面板,右边是独立的三维预览窗口:
-
选择 mod —— 自动列出工作区(本工具所在目录的上一级,例如
D:\dsh-x4)里 各工程的 mod 输出目录。游戏extensions/不扫(人多眼杂、还拖慢启动),要预览 装好的 mod 就用「浏览…」自己指过去——目录、.cat、.zip都收。 浏览框默认从工作区根开始;上次的位置只在工作区内才会被记住,想每次都从头 开始就把config.json的browse_remember设成false。 勾上「并排对比」后,对比对象可以选 vanilla,也可以选另一个 mod——改一版看一版时, 跟上一版比通常比跟 vanilla 比更有用。改完资产点「重载」即可,不用切来切去。 -
选择模型 —— 一个 mod 里往往不止一套模型(
build_all.py --outfits a,b会同时 产出 A/B 两套装扮,有些包干脆是几个角色放一起)。列表里每套都列出来, 点一下切换显示,多选就分屏并排;「全选 / 清空」是快捷方式。 选择会记住,下次打开还是你上次看的那几套。怎么判断"哪些文件算一套":先读 mod 的
libraries/character_macros.xml,每条 macro 的<models>就是一套(和游戏真正加载的一致);读不到 macro 才退回按 文件名前缀分组(yue_a_head+yue_a_body→yue_a),并且要求 heads/ 与 bodies/ 属于同一个模型目录,免得把"上一版 / 这一版"混成一套。macro 没引用到的.xac也会单独列出来,不会从列表里消失。 -
播放控制 —— 播放/暂停、逐帧、时间轴拖动、速度、循环、轮播(自动过一遍所有动画)、 网格/骨骼显示、重置相机、一键截图。鼠标拖动是模型跟着鼠标转(和 Blender、 three.js 一致);习惯反过来就用底部的「拖动反向」。
-
选择动画 —— 该角色 component 的全部动画(默认 186 条),带搜索框过滤。
选择会被记住(config.json),下次打开就是你上次看的 mod、模型和动画。
预览器要的是散装的目录:assets/**/*.xac、libraries/character_macros.xml、
libraries/material_library.xml、贴图 *.dds.gz,它按文件名和 xml 去找这些文件。
而 X4 的发布形态是 <mod>/ext_01.cat(文本索引:名字 大小 时间 md5)
ext_01.dat(所有 assets 按索引顺序拼在一起),玩家拿到的.zip里裹的又是这一对 ——assets 全在.dat里,所以把.cat或.zip直接指给预览器,它什么都找不到。
现在「浏览…」和命令行的 --mod 都能直接吃包:
| 你给的 | 会发生什么 |
|---|---|
散装目录(有 .xac) |
原地读,不复制、不写入 |
ext_01.cat |
自己找同名 .dat,解到 work/unpacked/<所在目录>_ext_01/ |
.zip 发布包 |
先解压,再把里面裹着的 .cat(通常在 <mod包名>/ 子目录里)一起解到 work/unpacked/<包名>/ |
只有 ext_01.cat/.dat 的目录 |
同上摊开(不动你的目录) |
摊开是有选择的,不会把整包照抄到硬盘上:
- 只写预览真正会读的:
.xac网格、.xml(macro / 材质规则)、贴图.gz/.dds; - 网格 + xml 超过 300 MB 就明确报错,而不是默默写满盘;
- 只有贴图超限时先放弃贴图照常摊开网格(官方 DLC 就是这种:网格 50
110 MB、 贴图 200900 MB),状态栏会写明"贴图超限先跳过(模型会没颜色)"; ext_01_sig.cat(签名清单,里面只有一堆 md5)不碰。
摊开是缓存的:第二次打开同一个包直接复用(毫秒级),源包更新过(大小/时间戳变了)
才重解。缓存就在工程内的 work/unpacked/(gitignore 已忽略),删掉即可重来。
多套模型的识别、贴图、材质裁剪规则在摊开后的目录上照常工作。
游戏的
extensions/不扫:那里面官方 DLC、工具类 mod、别人做的 mod 混在一起 (几十个条目,绝大多数跟人物动作无关),既拖慢启动又刷屏。要预览装好的 mod 就用 「浏览…」自己指过去——包装好的会摊开,官方 DLC 那种大包网格照摊、贴图自动跳过。 确实想让它常驻列表,把路径写进config.json的mod_roots。
python tools/viewer.py --vanilla # 只跑 vanilla,确认工具链正常
python tools/viewer.py --mod /path/to/your/mod # 与 vanilla 并排对比
python tools/viewer.py --mod <发布包.zip> # 直接吃 .zip / .cat(自动摊开)
python tools/viewer.py --body a.xac --head b.xac # 指定具体资产
python tools/viewer.py --mod <新版> --compare-mod <上一版> # 两个 mod 并排(迭代时最有用)
# mod 里有多套模型
python tools/viewer.py --mod <目录> --list-variants # 先看有几套、都叫什么
python tools/viewer.py --mod <目录> --variant yue_b # 只看其中一套
python tools/viewer.py --mod <目录> --all-variants # 每套各占一格
# 不开窗口,批量出图
python tools/viewer.py --mod /path/to/mod --anim anim_stand_idle_05 \
--shot out.png --frames 0,15,30,45窗口操作
| 键 | 作用 |
|---|---|
| 空格 | 播放 / 暂停 |
| ← → | 上一条 / 下一条动画 |
, . |
逐帧后退 / 前进(按 15 fps) |
| ↑ ↓ | 播放速度 |
B |
叠加骨骼线框(穿透显示,便于核对骨架) |
N |
只看骨架(隐藏网格) |
R |
重置相机 |
| 鼠标拖动 / 滚轮 | 旋转(模型跟着鼠标走)/ 缩放 |
Q / ESC |
退出 |
动画能播的前提是 mod 资产带着和 vanilla 一模一样的骨架。单独查这个:
python tools/skeleton_check.py --mod /path/to/your/mod输出每根骨的 bind 位置 / 旋转 / 缩放偏差与父节点差异,并给出结论。判据是 按骨名配对而不是按出现顺序——原版资产里同一套骨经常重复多次,按顺序比会误报。
GUI 是给人看的,AI 看不见窗口;只丢一张图,AI 还得自己猜哪里有问题。
ai_check.py 把"这版 mod 的动作对不对"翻译成可以比较的数字(全部相对
vanilla 基线),再附上对比图:
python tools/ai_check.py --mod <mod目录> --out report/产物:
| 文件 | 内容 |
|---|---|
report/report.json |
全部指标、mod/vanilla 比值、告警、结论(AI 读这个) |
report/summary.txt |
同样内容的人类可读版 |
report/shots/<动画>.png |
时间序列:mod(t0) vanilla(t0) mod(t1) vanilla(t1)… |
report/shots/<动画>_views.png |
环绕视角:同一时刻绕一圈(默认 4 个角度) |
图里有什么:mod 侧带 albedo 贴图(能看到颜色错位、纯黑、透明这类外观问题),
两侧都叠绿色骨骼线(穿透显示,可直接核对骨架与几何是否对得上)。
vanilla 侧保持纯色——它只是姿态基准,上贴图没意义还慢一倍。想关掉用
--no-textures / --no-bones。
贴图怎么找到的:xac 材质的 layer 里就记着贴图名(lumine.face → lumine_face_diff),
按它精确匹配,实测 lumine / boru 的网格 100% 命中。比拿材质名去猜准得多——
一个图集常被十几个材质共用(face / eyebrow / eyelash / teeth 全指向
lumine_face_diff)。
裁剪规则照抄游戏 shader,不是拍脑袋定阈值。mod 的 libraries/material_library.xml
里写着每个材质用哪个 shader、什么 blendmode:
| shader | blendmode | 怎么画 |
|---|---|---|
p1_hair |
ALPHA1 / TWOSIDED |
alpha < 0.5 丢弃(双面) |
p1_character |
NONE |
完全不裁剪(皮肤、衣服) |
p1_character |
TWOSIDED |
不裁剪,双面(裙子、飘带、披风) |
p1_character |
ALPHA1 |
alpha < 0.5 丢弃 |
阈值 0.5 来自 shadergl/glsl/p1/high/hair.frag.glsl 里的硬编码
if (ColorBaseDiffuse.a < 0.5f) discard;;而 p1/high/character.frag.glsl
一个 discard 都没有。
左:一律按 0.35 裁剪(半透明发丝边缘被留下,头发成了"白棕相间的噪点")。
中:按材质规则走 p1_hair 的 0.5。右:软光栅同一帧,两边一致。
这是整个项目里最费劲的一个 bug——我一开始靠试阈值(0.02 / 0.1 / 0.2 / 0.35 全试过), 其实该做的是去读游戏 shader 的源码。教训:能查证就别猜。
视角可调——单角度会漏掉背面、侧面、腋下、裙摆内侧:
python tools/ai_check.py --mod <目录> --out report/ \
--views 8 # 环绕 8 个角度(0 = 关闭)
--azimuth 200 # 时间序列那张的方位角
--elevation -20 # 仰角,负值从下往上看(查腋下/裙摆)命令行出图同样支持:viewer.py --shot out.png --views 6 --elevation -15。
mod 里有多套模型时,ai_check.py 默认只检查第一套,会先把清单打出来;换一套:
python tools/ai_check.py --mod <目录> --variant yue_b --out report/退出码 0 = ok、1 = warn、2 = fail,可直接接进 CI。
判据(越接近 1 越好):
| 指标 | 阈值 | 含义 |
|---|---|---|
joint_tear_cm |
> 1.35× | 顶点到主导骨的距离偏大,蒙皮可疑 |
feet_gap_cm |
< 0.85× | 两脚间距被收窄 = 猫步(几何间距,不是骨间距) |
ground_gap_min_cm |
与 vanilla 差 > 3 cm | 脚悬空或陷进地板 |
flipped_face_ratio |
> 0.1 | 绕序翻转,实机透明/看到内壳 |
| 顶点数 | > 15× | 实测会让空间站频闪 |
matched |
< 0.5 | 动画节点对不上骨架,动画驱动不了 |
两个容易踩的坑,已经在实现里绕开了:
- 两脚间距必须用几何顶点算。用骨位置算的话,因为 mod 与 vanilla 骨架逐字节相同, 两边会得出完全一样的假结论——而猫步本来就是"关节落在几何内侧"的几何问题。
- 绕序别用"面法线背离质心"那套。实测同一个 mod 躯干 0.63、头部 0.76,vanilla 反而
0.76、0.61,完全不可比,会把正常资产判成绕序翻转。改用带符号的
dot(面法线, 顶点法线)为负的比例,两边都是 0.000,干净。
工作区根还有一份 AGENTS.md,写明让 AI 改完 mod 后先跑这个自查再进游戏。
- 网格:
--mod目录(或.cat/.zip包,见上面"mod 从哪来")下的.xac。 哪些算一套模型优先看 macro 的<models>(每个<macro>一套),读不到 macro 才按文件名里的head/body分槽位。 一个目录里有好几套时全部列出:GUI 是"模型"列表,命令行是--list-variants。 - 动画:默认取 macro 实际引用的 component(
character_argon_female_01)。 Argon / Terran 等人类种族共用这套骨架和动画,所以 Terran 的 mod 也用它。python tools/animations.py <component名>可以列出任意 component 的动画清单。 - 想单独核对"多套模型有没有被认全",跑
python examples/variants_check.py [mod目录…]: 不开窗口、不需要游戏,逐个列出每套模型的槽位与文件,并检查有没有.xac漏掉。
"XSM " + u32 版本 + 头部字段 + 导出器/源文件/日期三个字符串
随后 N 条节点记录首尾相接:
[100 字节记录头][u32 名字长度][名字][位置关键帧段][旋转关键帧段]
记录头里与帧数有关的部分固定在 名字长度字段 - 0x50:
| 偏移 | 含义 |
|---|---|
| +0x00 | u32 位置关键帧数 |
| +0x04 | u32 旋转关键帧数 |
| +0x08 / +0x0c | 附加段帧数(老格式才有意义) |
| −0x04 | 单精度浮点(骨骼半径一类,未使用) |
关键帧有两种编码,同一文件内一致:
| 布局 | 记录头 | 位置段 | 旋转段 | 附加 |
|---|---|---|---|---|
| 新(X4 9.00 常见) | 100 B | 16 B/帧 f32 x,y,z,t |
12 B/帧 i16 x,y,z,w(Q15) + f32 t |
— |
| 老(2008–2011 导出) | 100 B | 同上 | 20 B/帧 f32 x,y,z,w,t |
20 B/帧的同构轨道 + 32 B 尾 |
- 坐标系与
.xac一致:Y 轴向上、单位厘米,旋转是相对父骨的局部旋转。 - 旋转段存的是绝对局部旋转(第 0 帧实测与
.xac的绑定旋转一致),直接套用即可。 - 关键帧时间是稀疏的(不保证等间隔),按时间插值;四元数用相邻帧 nlerp。
- 现代资产的节点名与
.xac骨架同名(Bip01 ...);老资产用root/spine1/l_upleg旧命名,和现役骨架对不上,只能作参考。
实现里对上述差异做了自适应:先在文件头部定位第一条记录并判定布局,之后按记录长度 链式推进(不扫全文件),落后时在 ±24 字节内重新对齐。
顺带记录一个
.xac的坑:材质 chunk 的 length 不包含 layer 数据 (实测length=129的 chunk 实际占 191 字节),照着 length 跳会立刻错位。
--mod PATH mod 目录,或 .cat / .zip 包(包会摊到 work/unpacked/ 再预览)
--variant NAME 只用 mod 里的某一套模型(名字见 --list-variants)
--all-variants mod 里每套模型各占一格并排显示
--list-variants 列出 mod 目录里的模型套,然后退出
--body / --head FILE 直接指定资产(磁盘路径或游戏包内路径)
--vanilla 只加载 vanilla 基准
--vanilla-body/--head 换对照用的 vanilla 槽位资产
--component NAME 取哪套动画(默认 character_argon_female_01)
--anim NAME 只播某条动画(逻辑名或资产名)
--delta 实验性增量姿态模式,见下
--shot OUT.png 不开窗口,直接出图;配合 --frames/--anims
--views N 环绕 N 个角度出图(0 = 只用 --azimuth)
--azimuth / --elevation 视角角度;elevation 为负是从下往上看
--width/--height 窗口或出图尺寸
关于 --delta:本意是用"相对 .xsm 记录头里绑定姿态的增量"去抹平动画文件与
网格资产的版本差。实测不该当默认:同一动画在不同资产上会给出不同姿态。
默认是直接套用动画值。
- 贴图只上 albedo(diffuse):normal / smooth / metal 通道和法线贴图没做, 所以"光照看起来不对"这类问题看不出来。透明只做了 alpha 裁剪(头发镂空能出来), 没做真正的半透明混合。
- 贴图只覆盖 mod 侧;vanilla 侧始终纯色。
ai_check.py的指标是筛子不是判决:长裙、大袖、披风这类远离骨骼的服装几何, 关节撕裂天然偏高,报警后要对着shots/的图确认再下结论。- 只播骨骼动画:morph / 表情(viseme、blendshape)还没做,脸部细节看不到。
- props 槽位(头发、胡子等)可以当普通资产用
--body/--head传,但没有自动配对。 - 同屏格数是按宽度均分的:同时看 4 套以上时每格会很窄(GUI 里"全选"也一样)。 默认只在模型不多时全部选中,超过就只选第一套。
- 摊开只覆盖预览要用的那几类文件,且先网格后贴图:贴图超限会被跳过(模型没颜色,
姿态照样能看)。缓存放在
work/unpacked/,不用了直接删。 .dat单独给没用——它只是数据,索引在同名.cat里(会明确提示)。- 自动发现只扫工作区(本工具上一级),不扫游戏的
extensions/;要常驻别的根 目录就写config.json的mod_roots。 - 一个目录里塞了好几个 mod(比如
work/dist)时,重名的模型靠目录提示区分 (lumine · x4_lumine_terran_add);同一套模型被复制到多处时,取离 macro 最近的那份。 - 老格式动画(
root/spine1命名)不能驱动现役骨架,播放时会提示匹配率。 - 软光栅出图是 CPU 实现,一张 400×660 约 0.25 s;实时窗口走 OpenGL,不吃这个开销。
run_studio.bat 双击启动图形界面(CRLF 行尾,别改成 LF)
tools/
studio.py 图形界面(tkinter 控制面板 + 独立 3D 窗口) <- 推荐入口
viewer.py 命令行预览 / 批量出图
modpack.py .cat/.dat 与 .zip 就地摊开成目录(带缓存)
scene.py 资产定位、场景组装、自动发现 mod 目录、识别 mod 里的多套模型
make_shortcut.py 生成 Windows 快捷方式(绕开文件关联问题)
console.py 统一 UTF-8 控制台(GBK 下打印 ✓ 会直接崩)
glview.py OpenGL 视图层:轨道相机、绘制、可手动步进的窗口
x4game.py 游戏目录探测 + .cat/.dat 随机读取
xac.py .xac 解析(骨架 / 网格 / UV / 骨骼权重)
xsm.py .xsm 解析(关键帧曲线)
rig.py 正向运动学 + 线性混合蒙皮
animations.py component -> 动画清单
ai_check.py 给 AI/CI 的成果检查:一条命令出 JSON 判据 + 对比图
metrics.py 量化判据(关节撕裂 / 两脚间距 / 离地 / 绕序 / 法线)
ddstex.py DDS + BC1/BC3 解码(mod 贴图是 .gz 包着的 DDS)
skeleton_check.py 骨架一致性校验
render.py 纯 numpy 软光栅(离线出图用)
examples/
offline_check.py 不开窗口跑一遍并出图的最小示例
variants_check.py 核对"mod 里的多套模型有没有被认全"(不需要游戏)
图形界面用的是 tkinter(Python 自带)做控制面板、pyglet 单独开三维窗口,
两者共享一个线程:tkinter 的 after 循环每帧调用 PreviewWindow.pump()
手动步进 pyglet,因此不需要两套事件循环,也不用加锁。


