你在启动器里勾选一个 MOD 并启动游戏后,游戏内便会加载对应的新职业、新装备或新地图。 那么介于二者之间的 .MOD 文件在结构上究竟是什么?它是如何被构建生成,又如何在游戏运行时被解析加载的?

本文将对整条链路展开深入剖析:MOD 源文件夹 → 五类二进制格式编译 → 容器封装 → 引擎加载解析。 内容涵盖完整的二进制协议规格(字节布局、哈希算法、底层函数地址),并针对“MOD 在启动器中勾选却不生效”等具体故障提供底层排查依据——这些故障均可追溯至本文所列的特定字段与逻辑分支。

材料来源:IDA 反汇编 EditorGuts.dll(官方编辑器 GUTS 的核心,32 位,imagebase 0x10000000)与 Torchlight2.exe(读取侧,imagebase 0x400000);frida 活体探针挂真实烘焙进程抓运行时真值; 对官方 shipped 数据与原生打包产物做逐字节比对;全语料 A/B 回归。文中所有 sub_XXXXXXXX 是绝对地址。

阅读建议:若仅需了解常见打包故障与规避方案,阅读 §0、§2.4、§2.5、§4、§8.2、§8.6 即可; 想自己编写工具链的读者,格式规格在 §2、§5–§7,附录 A 为地址速查表。


0. 十句话看懂 .MOD 打包

  1. .MOD 本质上是一个自带目录索引的压缩归档包与身份元数据。 三段式结构:头部(MOD 元信息)、 数据段(所有文件的 zlib 压缩块)以及清单(文件树 TOC)。
  2. 打包过程不仅包含压缩,还伴随二进制编译。 五类扩展名的文本源文件会被编译为特定二进制格式: .DAT / .TEMPLATE / .ANIMATION / .HIE → BINDAT,.LAYOUT → BINLAYOUT;其余文件保持原格式打包。
  3. 构建期还会额外生成两类关键数据:7 个 RAW 聚合索引(用于游戏启动时按类型快速定位单位、技能、词缀等资源的全局检索表), 以及为关卡布局生成的 .mpp 寻路网格数据。
  4. 游戏在加载阶段仅对一个哈希值进行强制校验 —— 即 rollingHash 若该哈希校验失败,整个 MOD 将被引擎静默丢弃, 这也是“启动器已成功勾选但在游戏内完全不生效”的最常见根因。
  5. 清单中的文件路径必须全部转换为大写。 游戏检索虚拟文件系统(VFS)时会将请求路径转换为大写,再与清单中记录的文件名按区分大小写(byte-exact)比对。 若磁盘上的小写文件名(如 .dds)被原样写入清单,将导致检索永久未命中——常见现象为头像或贴图静默回退至默认资源。
  6. MOD 的激活判定依据为 MOD_ID,而非物理文件名。 存档目录下的 modlauncher.sch 记录了已启用的 MODGUID 列表, 其书写顺序即为加载优先级;且 TL2 采用先挂载者优先生效机制(与常见的后加载覆盖机制相反)。
  7. .mpp 是唯一需要几何投射烘焙的生成物,也是离线编译中唯一受浮点精度影响而难以完全实现逐字节一致的格式——但以“实际通行可行性”为衡量标准, 离线烘焙算法已达到 99.850% 的网格一致率,且已消除所有可能阻挡玩家通行的拓扑障碍。
  8. 官方 DATA.PAK 归档中存储的并非原始文本。 例如其中的 FOO.LAYOUT 实际存放的是已编译的 BINLAYOUT 二进制数据; 社区散装 MEDIA 目录中可直接阅读的文本,均为解包工具逆向反编译还原的产物。
  9. GUTS 打包时会自动过滤 13 类非运行期文件(如 .XML.MAX.LOG、缩略图、工程源文件等)。 若第三方工具未实现对应过滤策略,在包含临时文件的脏工作树下会生成体积与内容不一致的包。
  10. 目前该全套打包链路已可完全离线运行:无需安装官方编辑器,无需初始化 D3D9 图形环境, 甚至可通过编译为 WebAssembly 的同源 Rust 代码在纯浏览器端独立执行。

1. 全景:从一个文件夹到一个 .MOD

一个 mod 的源码长这样:

我的MOD/
├─ MOD.DAT              ← 身份证:名字、作者、MOD_ID、版本、依赖、要删的文件
└─ MEDIA/               ← 内容树,镜像游戏自己的 MEDIA/ 结构
   ├─ UNITS/…*.DAT      ← 单位/物品/怪物定义(文本)
   ├─ SKILLS/…*.DAT
   ├─ LAYOUTS/…*.LAYOUT ← 关卡瓦片、UI 布局、粒子特效(文本)
   ├─ MODELS/…*.MESH    ← 模型、骨骼(已经是二进制)
   └─ …*.DDS/*.PNG/*.OGG/*.MATERIAL/…

打包构建流程包含四个有序阶段:

① 寻路烘焙      MEDIA/LAYOUTS/** 下的每个 .layout  →  同名 .mpp(计算可行走网格)
② 二进制编译    5 类文本源  →  BINDAT / BINLAYOUT
③ 生成全局索引  遍历资源树  →  7 个 RAW 聚合索引
④ 容器封装      Header + [所有文件的 zlib 压缩块] + 文件树清单  →  .MOD

官方编辑器 GUTS 走的就是这条路。其导出函数 CreateMod(0x100DE830)仅为入口包装, 实际的管线调度位于 sub_103FA610:

CreateMod @0x100DE830  (入口包装)
└─ BuildMod_orchestrate @0x103FA610
   ├─ A. 读 MOD.DAT 元数据 → header 槽位
   │     NAME / MOD_ID / VERSION / MOD_FILE_NAME / DESCRIPTION / AUTHOR /
   │     WEBSITE / DOWNLOAD_URL / REQUIRED_MODS(校验+哈希) / REMOVE_FILES

   ├─ B. "Generating Path Nodes"  →  Pathing_RegenAll_worker @0x10018750
   │     只扫 <mod>/MEDIA/LAYOUTS/**,逐 .layout 烘 .mpp
   │     ★ 注意它排在编译之前 —— §8.2 会讲这个顺序造成的经典疑难

   └─ C. "Compiling Mod" → CompileRawPack @0x103F5DA0
         ├─ PrePack @0x103F50D0
         │   ├─ 编译派发 @0x1029C9A0    5 类源 → BINDAT / BINLAYOUT,然后 DeleteFileW 删源
         │   ├─ RAW 派发  @0x1029BFA0    7 个聚合索引,子树非空才写
         │   ├─ 扫 MEDIA "*.*" + PNG→DDS 去重 + 剥 "MEDIA/" 前缀
         │   └─ 打包排除黑名单 @0x103F4340(静态表 @0x11D44D40)  ← 13 条后缀
         ├─ 建目录树 @0x102A6430   (type 码 @0x102A1EA0 + 最终解析 @0x102A24F0)
         ├─ Pack 编排 @0x1029BBB0
         └─ 写盘器  @0x102A7100   数据段 + zlib + rollingHash
         最后:3 个临时文件拼接 [header][pakdata][manifest],回填偏移,移入 <install>/mods/

上述调用拓扑系从 CreateMod 逐节点逆向跟踪梳理所得,所有关键分支均与离线实现进行了对账验证。 以下列出数项在实现中需重点关注的底层细节:

  • sub_103F5DA0返回值约定:0 表示成功,非零值为特定错误码(2 无路径 / 3 无文件 / 4 打开失败 / 5 临时文件重开失败 / 6 依赖 mod 加载失败 / 7 版本或递归依赖 / 8 读不到 Torchlight2.exe 版本)。 CreateMod 再把 0 翻成 1 返回给外部调用者。
  • 若工作目录存在 devbuild.txt,会翻转 manifest+88 处的标志位,跳过写入逐文件 mtime。由于游戏本身并不校验 ftime, 对实际运行无影响,但属于会导致二进制输出差异的隐式构建模式。
  • GUTS 采用增量编译机制(sub_1028FC00 检测到已有的 .BIN* 文件更新时间较新则跳过)。这可能引入中间产物与源码不一致的隐患; 离线工具链则采用全量重新编译策略——由于编译器已实现 byte-verified 级精度,产物稳定且无缓存一致性副作用。

2. 装箱:.MOD 容器

2.1 三段结构

out = header(可变长)          # off_data = len(header)
    + PAK 数据段              # [off_data, off_man)
    + manifest 文件树          # 从 off_man 起

若以 ZIP 格式类比:数据段类似于压缩数据区,Manifest 相当于中央目录(Central Directory),而 Header 则承载了归档的专属身份元数据——记录该 MOD 的全局标识 GUID、版本号及依赖关系。

off_data / off_man 在写入时先填 0 占位,三段拼完再 fseek 回填。

2.2 Header:mod 的身份证

写入器 sub_103F5DA0,读取器 sub_103FA610:

<HHQII>  ver(=4), modver, gamever, off_data, off_man
SS title; SS author; SS descr; SS website; SS download    # SS = u16 码元数 + UTF-16LE
<QIQ>    modid, flags, reqHash
<H> reqs_count;  每项: SS(name) <QH> mod_id, version
<H> dels_count;  每项: SS(path)

MOD.DAT 里的字段是这样落进槽位的:NAME→title、AUTHORDESCRIPTIONWEBSITEDOWNLOAD_URLMOD_ID→modid、VERSION→modver、REQUIRED_MODS→reqs、REMOVE_FILES→dels。

Header 字段的三项特殊机制:

  • MOD_FILE_NAME 不是 header 字段,它是输出文件名
  • modver = VERSION + 1。publish 路径执行 ++*(this+256),所以你在 MOD.DAT 里写 3,包里存的是 4。
  • gamever 不来自 MOD.DAT,而是打包时实读 Torchlight2.exe 的 VS_FIXEDFILEINFO (sub_103F8CD0,词序 (minorMS, majorMS, privLS, buildLS))。1.25.9.5 = 0x0005000900190001, 对一个安装来说是常量。GUTS 会用实读值覆盖你写的任何东西。

开发注意项:modid 在底层为 64 位有符号整数(i64),可能为负数。若作为无符号整数解析,会导致部分 MOD 的 GUID 计算错误。

2.3 reqHash:依赖图的指纹

如果一个 mod 在 MOD.DAT 里声明了 REQUIRED_MODS(按 GUID + 最低版本依赖别的 mod), header 里就会多出一个 8 字节的指纹。写入点在 sub_103F5DA0: v48 = sub_103F5500(this, 0) → 存 this+248 → fwrite,位置在 flags 与 REQUIRED_MODS 计数之间。

算法 sub_103F5500 是一条折叠链:

acc = 34832                       # 低半初值;空集提前返回 0
for (guid, ver) in REQUIRED_MODS: # 条目 stride 40:guid u64 / ver u16 / name wstring @+12
    t1  = H(guid_le_u64,  seed=0x22D0)
    t2  = H(ver_le_u16,   seed=lo32(t1))
    acc = H(t2_le_u64,    seed=lo32(acc))
return acc

H 为 sub_10285330,即 MurmurHash64B(m = 0x5BD1E995, r = 24)。逆向分析确认了其实现上的三处特化细节,直接使用通用标准库实现将导致哈希不匹配:

  1. 种子是 32 位,所以 h2 从 0 起,而不是常见实现里的 seed >> 32;
  2. 链式传递只取上次结果的低 32 位当种子,但哈希本身返回 EDX:EAX 的完整 64 位;
  3. name 字段不参与哈希。

端到端实测:两条 REQUIRED_MODS 的 mod 打出的 header 值 = 0x42E3B27898608F92

一条边界值得说明:GUTS 解析到一个已安装的依赖时,哈希的是已安装那份的版本,并且会把该依赖 自身的 reqHash 递归折进去。离线工具看不到"装了什么",只能用 MOD.DAT 里声明的版本、且不递归。 所以扁平依赖(声明版本 == 已安装、依赖自身无 REQUIRED_MODS)两边一致,更深的依赖图离线不可复现。

此外,REQUIRED_MODS 在社区实践中较少被严格配置,多数大型 MOD 系列更倾向于通过启动器的显式加载顺序约定(由用户手动调整排列)来配合 §2.7 的覆盖机制运作。

2.4 Manifest:文件树,以及那个大写陷阱

写入器 sub_102A5860:

<HI>  版本(=2), mhash
SS    root("MEDIA/")
<II>  file_count, dir_count
每目录: SS(dirname) <I> rec_count
        每条: <IB> crc32, type   SS(name)   <IIQ> off, size, filetime
  • 目录树:文件按父目录 key 进 std::map<wstring,…> → 目录按 UTF-16 路径序输出; 每个目录给自己的子目录留一个 type-7 占位条目。根是 MEDIA/
  • 条目里的 off相对数据段起点的偏移,off_data + off 才是文件内的绝对位置。
  • filetime 是源文件 mtime 转成的 Windows FILETIME。游戏不校验,纯元数据。

⚠️ 文件名必须全大写 —— 这条会造成最难查的一类故障。

GUTS 在收集文件时就把名字 upper() 了(sub_103F50D0)。而游戏的 PAK 查找是这样做的: 把请求路径转大写,然后跟清单里存的名字按原样比对(它假定存的已经是大写)。 于是磁盘上一个小写的 QLJX_F.dds,如果原样存进清单,游戏拿 QLJX_F.DDS 去查就永远匹配不到。

此类故障的表面症状往往具有较强欺骗性:职业数据、名称与技能均能正常加载,仅角色头像异常显示为其他默认资源。 因为职业和名字走的是不区分大小写的查找,只有贴图那一步是敏感的。追下去的链路是 CLASS_XXX_F.DAT<STRING>ICON:.IMAGESETImagefile="…/QLJX_F.dds", .IMAGESET 在磁盘上恰好是大写(匹配上了),而它引用的那个 .dds 是小写(没匹配上)。

str.upper() 与 GUTS 行为一致:ASCII 转大写,CJK 与其它非 ASCII 字符不变。

工具开发注记:Manifest 中记录了每个文件的修改时间(mtime)。因此在对两组打包产物进行字节级回归测试时,必须在同一文件系统树上执行;若通过 cp -r 复制后分别打包,文件属性中的时间戳差异将直接导致清单区域的二进制不匹配,属于测试基准偏差而非实现缺陷。

2.5 数据段与 rollingHash:核心加载门禁

写入器 sub_102A7100:

<II>  maxCompressedBlockSize, rollingHash          # 8 字节头
每文件(manifest 序): <II> 解压尺寸, 压缩尺寸(0=stored) + 字节流

maxCompressedBlockSize 是最大压缩块尺寸,喂给游戏解压时的读缓冲。

存储还是压缩,由一张表 byte_11E94CD8[type] 决定:type 0..23 全是 1,只有 type 24(.JPG)是 0。 另外任何 block ≥ 0x1900000(26 MB)也直接 stored —— 这个常量在 sub_102A7100 里出现 5 次。 所以规则一句话:.JPG 和超大块外,全部 zlib

关于 rollingHash:这是游戏唯一会校验的哈希,也是“启动器勾选却不生效”的核心根因。

若该哈希计算不一致,游戏加载器将在无任何弹窗或报错提示的情况下直接放弃挂载该 MOD;其内部虚拟文件表被整体丢弃,导致游戏内完全不出现 MOD 内容,而在启动器界面上该 MOD 仍处于勾选状态。

写入(sub_102A7100 末尾)与校验(sub_102A2690)的算法对称且确定:

N       = 数据段长度
divisor = 25 + (695696193 * N  mod 2^32) mod 51
stride  = max(2, N // divisor)
h = N
for off in range(8, N, stride):     # 偏移 0..7 的头不参与
    h = (int8)data[off] + 33*h      # mod 2^32
h = (int8)data[N-1] + 33*h          # 再叠加最后一字节
rollingHash = h

此处存在一处精妙的设计细节:表面上看反汇编逻辑中的 divisor 来源于伪随机数生成器(LCG),看似输出不可复现;但实际上,该 LCG(sub_10285B30)在调用前已被 sub_10285A50 以数据段长度 N 作为种子显式重置(sub_10285450 负责保存并在计算后恢复旧状态)。因此所谓的“随机除数”,本质上完全是数据段长度的确定性单射函数。该结论已在 30 个官方及编辑器生成的 .MOD 文件中得到逐字节验证。

另一项关键特性在于采样稀疏性:该算法仅跨步采样约 50 个散列字节(步长约为 N/25 至 N/75)。这一特性为后续浏览器端 WebAssembly 的流式轻量计算提供了关键支撑(详见 §10.2)。

2.6 三个哈希/计数,哪个是真的

容器里一共有三个看起来像校验的字段,但只有一个真的会被校验:

字段位置游戏是否校验说明
PAK rollingHash数据段头第 2 个 u32错了整包静默丢弃
manifest mhashmanifest 头否(读而不校)原生由 sub_1028E6F0 随机派生,写 0 也能跑
manifest FileCountmanifest 头否(容量提示)官方自己的值(如 862)都 ≠ 实际记录数(~618),照样加载

游戏在实际遍历清单时依赖 DirCount 及各目录自身的条目计数,FileCount 并不作为边界判定条件。换言之,mhashFileCount 的数值偏差不会引发加载异常,而 rollingHash 出现单比特偏差即会导致整个归档失效——这种校验上的不对称性需格外注意。

2.7 游戏怎么加载、怎么激活

加载链路:sub_103FB240(报 "Unable to load mod.\nFailed because :")→ sub_103F8BC0sub_103F83C0(真正的校验)。它依次做:

  1. 已加载则直接返回;
  2. 文件表或 offMan 为空 → 静默失败(注意,没有报错);
  3. 解析 REQUIRED_MODS 依赖,缺失或版本不对会记日志;
  4. 比对 reqHash;
  5. sub_102A3320 读 manifest 版本(> 2 拒绝)、读 mhash(不比对)、 然后 sub_102A2690 重算并比对 rollingHash —— 不等就 fclose; return 0,又是静默。

激活按 MOD_ID,与文件名和哈希都无关。 存档目录下的 modlauncher.sch (UTF-16LE + BOM + CRLF)里一行一个 <INTEGER64>MODGUID:<modid>,游戏加载 header modid 匹配的那个 .MOD

加载顺序与优先级机制:TL2 严格遵循“先挂载者胜”(First-Mount-Wins)原则。 该逻辑与多数现代游戏引擎中常见的“后加载覆盖”机制恰好相反:

  • 引擎先按 scheme 顺序挂 mod PAK(sub_7DEA10),之后才挂 vanilla 的基础 PAK;
  • 路径 VFS(sub_68F630)的文件条目按挂载顺序尾部 append,查找时从头正序扫、命中第一个就返回

⇒ mod 覆盖 vanilla;mod 之间,.sch 里靠前的覆盖靠后的。 用户视角:启动器列表里越靠上 = 优先级越高。 覆盖粒度是整文件替换,不做字段合并。


3. 打包会悄悄扔掉哪些文件

GUTS 在收集文件时,会按后缀剥掉 13 类(sub_103F4340 + 静态表构造于 sub_11D44D40 @ unk_13E51C50):

类别后缀
工具与中间产物.CMP .THUMBNAIL.PNG .XLS .XML .MAX .MPD .LNK .LOG
编译源.DAT .LAYOUT .ANIMATION .HIE
编译产物别名.LAYOUT.BINDAT

第一类包含美术工程文件(.MAX)、策划表格(.XLS)、日志、快捷方式及缩略图等开发中间产物,属于工作目录中的非发布数据。若工具链未执行过滤,将导致打包体积冗余并暴露源码工程资产。

第二类并非丢弃,而是发生了名称转换与格式编译。GUTS 编译后把文本源剥掉、带着 <源名>.BINDAT 走, 写清单时再把名字改回源名。所以最终清单里是 FOO.DAT 这个源名,里面装的却是编译后的 BINDAT 字节

怎么确认这一点? 与其只读反汇编,不如拿真 GUTS 的产物当 ground truthEDITORMOD.MOD 是 GUTS 编辑器的默认输出名,任何用编辑器打开过的 mod 目录里都有一个。 扫它清单区的 UTF-16LE 名字,数一数就清楚了:

.DAT       936 条
.LAYOUT    160 条
.ANIMATION   6 条
.BINDAT      0 条     ← 编译产物不单独出现
.BINLAYOUT   0 条
.XLS / .XML / .MAX / .LOG / .THUMBNAIL / .CMP / .MPD / .LNK   全 0    ← 黑名单确实剥了

这一实测统计直接印证了过滤规则的实际运作效果。


4. 哪些文件会被编译

打包时对每个文件要回答两个问题:它是什么类型(type code,写进清单),以及要不要编译。 两个答案来自同一张表 —— sub_102A1EA0(原始码)+ sub_102A24F0(最终解析 + 追加编译后缀),共 20 个 type:

type扩展名type扩展名
0.DAT .TEMPLATEBINDAT11.IMAGESET
1.LAYOUTBINLAYOUT12.TTF .TTC
2.MESH13.FONT
3.SKELETON16.ANIMATIONBINDAT
4.DDS17.HIEBINDAT
5.PNG18未知/无扩展名
6.WAV .OGG19.SCHEME
7目录(占位)20.LOOKNFEEL
8.MATERIAL21.MPP
9.RAW23.BIK
10.UILAYOUT24.JPG(唯一 stored)

会被编译的只有 5 个扩展名。编译发生在 sub_1029C9A0(GUTS 内部叫 "convert text files to binary"), 它编完就 DeleteFileW 删掉源文本。

该映射规则在实现中极易因边界处理不当导致严重缺陷,以下为两起典型故障案例:

案例一:.TEMPLATE/.ANIMATION/.HIE 漏编。 早期实现中仅处理了 .DAT 文件, 而类型映射表中已将上述三类扩展名统一定义为 BINDAT(type 0)。结果是原始文本数据被直接装入类型标记为二进制的条目中, 游戏引擎在按节点树反序列化时必然发生解析错误。通过对比原生 DLL 与离线产物的全量条目, 迅速确认了这三类扩展名的类型分配缺失。

案例二:UI 界面整体丢失。 LAYOUT→BINLAYOUT 的触发条件曾被错误绑定于“同级目录是否存在现成的 .BINLAYOUT 文件”。 在一个纯源码仓库上(.gitignore 排除了 *.BINLAYOUT)它编译出 0 个 layout, 打出来的包里一个 UI 布局都没有 —— 而基础游戏的 UI 目录是 61 个 .LAYOUT 配 61 个 .LAYOUT.BINLAYOUT, 游戏读的是后者。于是进游戏 UI 整个不见了

两起案例的工程启示是一致的:文件类型的分类映射与编译调度必须基于同一套状态机与映射表,严禁在不同阶段采用不一致的启发式判断。

特殊特例:.IMAGESET 文件无需编译。基础游戏 ships 55 个 .IMAGESET0 个 .BINIMAGESET,游戏直接读文本,原样打包即可。


5. BINDAT:游戏所有数值的容器

物品、怪物、技能、词缀、配方、掉落表…… 游戏里几乎所有"数据"都住在 .DAT 里,编译后就是 BINDAT。 它是一棵递归的节点树,每个节点有一组属性。

5.1 格式

Header 12B: <III> version(=2), string_count, first_id
String table(按 id 升序):
   entry0 = <H>len + wchar[]        # 第 0 个无 id 前缀(id 在 header 的 first_id)
   entryN = <I>id <H>len + wchar[]
Body = 递归节点:
   <II> name_hash(rg_hash), prop_count
   每 prop: <II> key_hash(rg_hash), type + value(type ∈ {3,7} 为 8B,否则 4B)
   <I> child_count + 子节点…       # 源文本顺序

类型编号:INTEGER→1、FLOAT→2、UNSIGNED INT→4、STRING→5、BOOL→6、INTEGER64→7、TRANSLATE→8。

5.2 键名不存字符串,存哈希

这是 BINDAT 最重要的设计。节点名和属性名([UNIT]NAMELEVEL…)不进字符串表, 而是用 rg_hash —— GUTS 的 32 位串哈希(sub_100CA9A0;游戏侧是同算法的 sub_4C9FE0)—— 算成一个 u32 写进去。

该机制的优势在于任何 key 均可直接序列化,不存在字典完备性依赖:MOD 中即使自创全新的属性名,亦可直接通过哈希完成编码。全语料实测表明,901,025 / 901,028 个 key 严格满足 rg_hash 映射(3 个例外系原版已损坏文件所致)。其相应的代价则是单向哈希不可逆(详见 §5.5)。

字符串编码细节:底层解析需启用 surrogatepass 策略。编辑器按 wchar 逐字符直接读写,不校验 UTF-16 代理对的合法性。例如官方 TAGS.DAT 中存在将颜色字节流误拼入字符串值的非规范数据(会被解析为孤立代理字符),若按严格 UTF 规则校验将抛出异常,必须采用宽松的代理解析以确保字节级的 round-trip 一致性。

5.3 字符串值的 id:逐文件解析

STRING / TRANSLATE 类型的不内联,存的是字符串表里的一个 id。

官方产物里这个 id 看起来是全局会话计数器分配的 —— 编译整个游戏时一路 counter++。 但游戏是逐文件解析的:每个 BINDAT 自带一张表,body 里的 id 用本文件的表去查。

反向验证:原版基础数据中包含 565 处跨文件字符串 ID 碰撞(即相同的整数 ID 在不同文件中对应完全不同的文本内容),而游戏均能正常加载运行。这证实了字符串 ID 仅需保证局部单文件内的唯一性即可。

id 的具体值无所谓,只要文件内唯一。 所以离线打包器采用基于单个文件的哈希映射(rg_hash(s) 结合文件内线性探测)方案,具备无共享全局状态、无锁并行化与确定性输出等优势,已在游戏内验证。

如果你要的是逐字节复刻官方产物(比如做兼容性测试),还有第二种模式: 切到 corpus 全局 id,即 sub_10289A40 / sub_1023E9F0 的确切语义 (已知串查重建的字典、未知串按首现序 max_id+1 递增)。对 shipped 语料 15976 / 16084 字节精确, 性能反而略快(免掉每文件的排序探测)。默认不用它,因为那张字典带 715 处需要 majority-vote 的 id 碰撞, hash 模式的"文件内唯一"更安全。

5.4 精度验证:31 处二进制差异的底层根因

全 16084 文件语料的成绩:15976 字节精确(99.329%); 另有 77 个字节不同但语义完全相同(串 id / 表序噪声)= 99.807% 语义正确; 真结构差只有 31 个(0.19%),0 个编译错误。

这 31 处差异的成因非常具有代表性。其中 30 处属于官方 shipped BINDAT 与其自身文本源码存在舍入不一致:规范编码器按标准解析时输出规范的 float32。以 QUAKE1 条目为例,其文本记录为 "1",标准单精度编码应为 1.00x3F800000),而官方数据中记录的却是 1.00000010x3F800001)。这显然是官方 GUI 编辑器在内存浮点运算与序列化导出过程中引入的微小舍入误差。其余差异主要分布在 GRAPHS/STATS 与 POTIONS 曲线的 Y 轴数值中,属于同类成因。

第 31 处差异为 TAGS.DAT:GUTS 会将未命名的空根节点按当前文件名赋予哈希(官方数据中根节点的 name_hashrg_hash("TAGS")),加之前述的孤立代理字符异常,导致了结构上的微小偏差。

5.5 反过来:BINDAT 反编译器

既然 key 只存单向哈希,BINDAT 能不能变回可读文本?值可以(串表 + 内联都在),名字得靠查表反推

做法是内嵌一张词表:把基础游戏全部 16084 个 DAT 的 1678 个 distinct key/section 名收进来 (23 KB,而且 rghash 零碰撞),按哈希反查即可。查不到的发 UNK_<hex>。 输出是递归的 [name]…[/name] 文本、tc→类型标签、bool→true/false、GUID int64→十进制,UTF-16LE + BOM。

UNK_<HEX8> 属于规范的转义标识而非普通占位符。 编译模块对此进行了专门匹配:一旦检测到该固定格式,便会直接将提取到的原始哈希值原样写回二进制节点,从而确保反编译后再编译的逐字节一致性。匹配规则十分严格(要求固定长度与大写十六进制字符),不符合规范则按普通字符串处理。

⚠️ 注意:切勿手动修改 UNK_ 中的十六进制数值——修改该值等同于修改底层哈希寻址,将导致属性重定向至未知字段且不会产生报错。

词表覆盖缺口的特征分析:经过对 80,505 个 MOD DAT 文件的扫描,未命中的词条绝非任意命名的标识符,而是原版预设数值序列的扩展。原版数据中仅包含 LEVEL1..16CHILD1..5ENCHANTCOST1..4VALUE1..5TIER1..3_DESCRIPTION 等固定区间,而大型 MOD 普遍扩展至 LEVEL100CHILD7ENCHANTCOST5 等更高级别。统计显示全新自定义 key 仅有 20 个,而 104 个未识别 section 绝大部分属于 LEVEL17..100。向词表中补充二进制字面量仅能作为兜底,对于 MOD 运行时自创的键名,工具链将在打包阶段提供警告提示。


6. BINLAYOUT:场景、UI 与特效

.LAYOUT 描述"东西摆在哪":关卡瓦片里的每块石头、UI 的每个控件、粒子特效的每个发射器。 它编译成 BINLAYOUT,格式是 schema 驱动的、逐 descriptor 编码:

Header: <B>0x0B <B>flag(=4) <I>dg_off <H>obj_count(顶层)
Object(递归):
   <I> block_size  <B> descriptor  <q> id
   str NAME(仅当 != descriptor 默认名时写)
   <B> prop_count   每 prop: <H>mem <B>code + value
   <I> adprop_region   <H> child_count   + 子对象…

6.1 Schema 的提取与构建:避免经验归纳的陷阱

BINLAYOUT 的编码完全依赖一张 schema:哪个 descriptor 有哪些属性、每个属性的 mem 编号和类型

最初的设计常试图通过扫描官方现有的 .LAYOUT.BINLAYOUT 文件,逆向归纳属性映射关系。然而实践证明该方法存在隐蔽而致命的缺陷:

  • 它只能覆盖官方数据恰好用过的属性。遇到没见过的属性就静默丢弃 —— 丢了属性,block_size 就写错,进游戏 CEGUI 直接崩。
  • 它还会学进污染:Music descriptor 被学出 48 个属性,真实只有 4 个。

严谨的构建方案应当直接从 DLL 中导出运行期的 Descriptor 完整注册表:headless InitEditor → descriptor-mgr 全局 unk_12670228*(mgr+0x1C) BST 根 → 中序遍历, 每个 descriptor 取 code(+0x58)、属性列表(+0xE4[0..+0xE8])、每个属性的 code / flag / name / group / type。 结果是 159 个 descriptor / 2258 个可序列化属性,零语料输入。

两者的表现具有决定性差距:MOD 语料库的编译成功率从 18/13224 跃升至 13214/13224

6.2 序列化规则

  • 过滤器(写入器 sub_10115320,逐对象):一个属性被写出当且仅当 (prop->flag@0x50 & 0x10040200) == 0(bit9 = 编辑器专用、bit18 = 走 datagroup、bit28)。
  • 变换默认跳过:FORWARD(40) 在 Z==1.0 时丢、RIGHT(41) 在 X==1.0 时丢、UP(95) 在 Y==1.0 时丢 (POSITION 42 从不丢)。GUTS 在编译时把 identity 朝向丢掉,手写的 layout 尤其要注意这条。
  • Group 的属性走另一条路:CHOICE / RANDOMIZATION / NUMBER / TAG / ACTIVE+DEACTIVE THEMES / LEVEL UNIQUE / GAME MODE 不进对象属性,而是写进文件尾部的 datagroup 节点。
  • Logic Group 的连线图和 Timeline 事件放在 ADPROP 区,链接的输入输出名是内联字符串而非解析后的 id。

精度指标:基础 MEDIA 达到 8965 / 8985 字节精确。其余 20 处不匹配系官方 shipped 数据中包含未初始化的内存残留(源文件中某属性被误配置为 <STRING>,导致写入器读取到了陈旧的脏指针数据),在语义层面上已实现 100% 正确。MOD 语料库达到 13214 / 13224

通过逐指令逆向与函数级审计,进一步修正了数处关键细节,供编码器开发者参考: CHOICE @16大小写敏感的精确匹配 ["ALL","Weight","Random Chance"]; GAME MODE @27 是非空值 2-(v=="NORMAL") 的精确比较(其它非空值 = 2,不是 0); @25 是 Group 自己的 NO TAG FOUND bool(默认 0),不是硬编 0。


7. 7 个 RAW 索引:引擎的目录卡片

游戏启动时不会去遍历整棵 MEDIA 树找"有哪些技能"。它读的是 7 个预生成的聚合索引。 写入分派器 sub_1029BFA0,各扫对应子树、非空才写:

RAW写入器结构要点
AFFIXESsub_103C4170*.DAT<H>count;每项 SS(FILE) SS(NAME↑) 4×i32(MIN_SPAWN/MAX_SPAWN/WEIGHT/DIFF)+ UNITTYPES / NOT_UNITTYPES 两个字符串列表
SKILLSsub_102ECFD0*.DAT<I>count(仅非空 NAME);SS(NAME↑) SS(FILE) <q>UNIQUE_GUID
MISSILESsub_102FB490*.LAYOUT<H>count;SS(FILE) + 每个 DESCRIPTOR:Missile 对象的 MISSILE NAME↑
TRIGGERABLES*.DAT<H>count;SS(FILE) SS(NAME)
UIsub_103178E0*.LAYOUT<I>count(Menu Definition 且 MENU NAME 非空、非 DO NOT CREATE);含 TYPE/GAME STATE 枚举与 KEY BINDING
UNITDATAsub_1026CC50*.DAT4 类(ITEMS/MONSTERS/PLAYERS/PROPS);字段走完整 BASEFILE 继承链
ROOMPIECES*.DAT<I>count;每项 SS(FILE) + GUID 列表

扫描序有两种,而且必须分清楚,否则字节对不上: AFFIXES / SKILLS / UNITDATA / MISSILES 是 name-interleaved DFS(文件与子目录按名字混排、就地递归); TRIGGERABLES / UI / ROOMPIECES 是 files-before-dirs。7 个全部能逐字节复现官方产物。

工程实践结论:七类 RAW 索引中,仅 UNITDATA 显式依赖基础游戏(Base Game)数据。

EncodeUnitssub_1026CC50)在构建索引时,会沿着 BASEFILE 继承链回溯读取基础游戏的数据文件,解析并提取 UNITTYPE、LEVEL、RARITY 与 CREATEAS 等关键属性。游戏运行时的读取侧 sub_660560(通过 sub_661480 CUnitResourceList)解析并常驻这些元数据,且基于 UNITTYPE 构建运行时查询索引——若缺失这些属性,将直接破坏运行时的刷怪(Spawn)与战利品掉落判定机制。

这也解释了为何纯浏览器环境下的打包器必须内置基础游戏 UNITS 模板数据:若一件装备继承自 UNITS/ITEMS/BASE.DAT,在缺失 Base 数据时编译会导致关键的 CREATEAS=EQUIPMENT 标记位丢失。相反,其余 6 种 RAW 索引完全独立于 Base 数据,因此纯职业/技能类 Mod 即使在零 Base 环境下,也能编译出逐字节一致的成品

NOTE

GUID 跨文件格式的类型差异:同一个 GUID 标识符,在 .DAT 中序列化为 <INTEGER64>GUID:,而在 .LAYOUT 中则表现为 <STRING>GUID:。二者数值相同但数据类型不同,序列化与反序列化时必须分别适配。


8. MPP 寻路网格:碰撞与可走性烘焙

8.1 网格数据结构与烘焙流水线

每个关卡 .layout 均伴随一个同名 .mpp 寻路网格文件(基础游戏中累计 1293 个),由写出器 sub_10200920 负责构建:

24 字节 Header: <iiffff> gridW, gridH, worldExtX, worldExtZ, boundsX, boundsZ
紧随其后为 gridW * gridH 字节的栅格数据,采用行主序排列(X 坐标递增最快)

每个栅格单元(cell)代表 0.4 世界单位,栅格取值仅有三种状态:0x00(可行走 / Walkable)、0x01(障碍 / Wall)、0xFF(边界外或无地面 / Void)。 文件尺寸恒等于 24 + gridW * gridH 字节。

网格包围盒由场景中各 Region 的碰撞 AABB 联合计算得出——需特别注意,绝不能采用渲染 Mesh 的包围盒,后者在引擎中被刻意膨胀放大以支持视锥裁剪,若用于寻路会导致包围盒虚大。各 Region 坐标按 10 的倍数对齐并追加 0.2 的 Padding,最终网格尺寸由反推的 float32 Origin 决定。

分类器(位于 sub_10200920 内部)对每个网格单元执行三步判定:

  1. y + 200 沿竖直向下方向投射射线至 −200,获取最近命中点;
  2. |hit.y| > 80hit_type == 100,判定为障碍(墙)
  3. 否则在角色头部高度(+1.5)向 $\pm X$ 与 $\pm Z$ 四个轴向分别探测 0.30000001 单位。只要任一方向命中 NOPATH 几何体,即判定为障碍(墙);若四向均未命中,则判定为可行走

判定完成后,执行一轮 Enclosure 封闭区域二次扫描,将处于狭窄锐角死腔内部的网格补充为墙体。

判定机制的两处关键特征:

  • 无动态坡度限制:常识上陡坡会导致无法通行,但通过 DLL 动态打补丁实验确认,第 2 步中 |hit.y| > 80 的高度门限在绝大多数常规关卡模板中实际上是常闭分支;墙体的生成在绝大多数场景下由 NOPATH 几何完全主导。
  • NOPATH 标记仅有两个来源:Room Piece 原型定义的 NOPATH 属性(偏移 [+0x192]),或碰撞 Submesh 的材质名包含子串 nocollide。后者依赖字符串子串匹配,且引擎资产库中的实际命名格式为全小写multi_collision/nocollide

8.2 GUTS 编译次序缺陷与 2.5 KB 寻路失效故障

在 Torchlight II Mod 开发社区中,曾长期存在一条典型故障现象:角色进入自制副本地图后完全无法移动

该故障的经典复现路径为:在 GUTS 中打开 Mod 工程后,若在构建前手动清理工作区内的 .BIN*.MPP 等中间文件(为了确保 Clean Build),紧接着执行 Build。构建输出的 .mpp 文件大小均为精确的 2.5 KB(2524 字节)。打包运行后,关卡内所有网格皆不可通行。社区流传的临时排错方案是“再次点击一次 Build”。

早期基于黑箱观察总结的行为模型:

阶段 A: IF BINLAYOUT 存在 → 基于 BINLAYOUT 烘焙 MPP;  ELSE → 生成默认 Stub 网格 (2.5 KB)
阶段 B: IF BINLAYOUT 存在 → 校验 CRC32,不匹配则重编; ELSE → 由 LAYOUT 编译出 BINLAYOUT

在 GUTS 启动加载工程时只执行阶段 B;点击 Build 按钮时则按阶段 A → 阶段 B 的时序串行调用。这导致“先启动 GUTS 后修改源文件”与“修改源文件后再启动 GUTS”产生不同的编译中间态。

逆向分析彻底明确了该现象的底层因果链:

  • 调度管线执行次序倒置:在总调度函数 sub_103FA610 中,MPP 烘焙流程(Pathing_RegenAll_worker @ 0x10018750)硬编码调度在关卡布局编译(LAYOUT $\rightarrow$ BINLAYOUT,sub_1029C9A0之前
  • 烘焙器依赖运行时二进制解析:MPP 寻路网格生成依赖引擎运行时的关卡加载流水线(CLevel_LoadLevelData @ 0x1020AB90),而非直接解析源文本。该加载器仅能读取预编译的 .BINLAYOUT
  • 回退机制产生 Stub:当干净工作区内不存在 .BINLAYOUT 时,关卡加载彻底失败。引擎随后回退到默认的 $50 \times 50$ 栅格包围盒,并生成全为 0xFF(完全不可通行)的默认网格。
  • 尺寸精确匹配:Stub 网格的字节量为 $24\text{ 字节 Header} + 50 \times 50\text{ 字节} = 2524\text{ 字节}$,恰好呈现为文件管理器中的 2.5 KB。全图填充 0xFF 导致角色出生后失去一切可行走地面,直接锁死在原地。

在开发无头(Headless)原生烘焙工具时,同样必须绕过该次序依赖:需显式组织双阶段调度(Pass 1 编译 BINLAYOUT 与 Stub,Pass 2 依赖已落盘的 BINLAYOUT 烘焙正式 .mpp)。此外,由于原版引擎宿主进程退出时经常在内存清理阶段触发 0xC0000374(堆损坏,Heap Corruption)崩溃,因此执行成功与否不能依据进程退出码(Exit Code),而必须直接以有效 .mpp 文件的生成数量与尺寸为判定基准

TIP

纯离线编译管线的优势:离线实现严格保证拓扑编译次序(先解析文本全量输出 BINLAYOUT,再将内存对象交付给寻路网格生成器),从根源上消除了中间状态残留与双重构建需求。

8.3 离线寻路网格生成算法:0.29% 差异的排查与归因

在离线状态下复刻寻路网格生成流水线,核心挑战并非网格单元的三步分类逻辑,而是交付给几何分类器的三角形面片集合(Triangle Soup)的准入一致性:即精确判定哪些 Room Piece 的碰撞几何体会参与烘焙,哪些必须予以剔除。

初代离线实现的逐格准确率达到了 99.71%。剩余的 0.29% 差异最初被推测为“不可约的动态差异”——差异高度集中于带有 nocollide 材质标记的洞穴装饰物(如石笋、发光真菌、散落石砾与悬挂藤蔓),且判定似乎因实例而异:对于同一份 Mesh 资源,编辑器在部分位置会执行烘焙,而在另一些摆放位置却予以剔除,且在源文本 .LAYOUT.DAT 中均未见直观的静态开关。这曾导致一种误判:认为该差异源于编辑器未序列化的内部运行时状态。

然而,通过 Frida 对真实烘焙进程进行动态插桩,逐个捕获各 Piece 过滤门(Gate)的输入参数后,该假设被完全推翻。 这 0.29% 的差异实为六个相互独立、完全可静态推导的规则缺口:

#规则缺口逆向真相与机理修正收益
1DEACTIVE THEMES 过滤分支离线算法最初在过滤背景装饰时仅判定了 CHOICEACTIVE THEMES。逆向确认 DLL 中 sub_1022FF80 存在针对 DEACTIVE THEMES 的校验分支(包含 5 个 Theme 字符串槽位)。此前被误判为“动态随机”的装饰物,全数挂载在显式声明了 DEACTIVE THEMES=... 的 Group 节点下。多余障碍(Over-wall)减少 6307 格
2废除链接名称启发式匹配早期实现依据链接名称中是否包含 SPAWNER / RANDOM / CHEST 等关键词推断是否烘焙。逆向证明 DLL 从不基于字符串子串进行启发式过滤,而是无差别递归遍历所有链接对象,并对每个 Sub-piece 统一应用过滤门。重构为“遍历全链接 + 完整门限判定”后彻底剔除了脆弱的人工名称白名单。差异减少 5869 格,改善覆盖 49 张地图,0 处回归
3Room Piece 变换层级隔离在多层 Room Piece 嵌套(通常由 GUTS 编辑器内部的复制/粘贴操作残留引发)时,局部空间变换不向下传递。逆向代码确证:引擎中依据 PARENTID 递归叠加世界变换矩阵的函数,全局唯一调用方为 QuestController,烘焙流程从未接入该调用。若错误继承变换,深层嵌套对象的缩放会发生指数级累积膨胀(实测 5 层嵌套累乘达 162 倍),致使 Region 包围盒扩散至 13 万世界单位进而引发构建崩溃。修复 3 张因尺寸超限退化为 Stub 的地图;18/18 样板网格 Header 与官方产物完全一致
4NEVERBAKE 语义倒置该属性名称极具欺骗性。逆向 sub_10263280 显示 NEVERBAKE 对应描述符偏移 descriptor + 0x40;而在 SetMesh 阶段存在 if (descriptor[+0x40]) piece[0x191] = 1,最终判定门逻辑为 `ALWAYSBAKE
5Controller DATA 字段覆盖Layout Link Controller 中的 DATA 字段(格式为 1,8, 后附 8 组各对象局部位移)会强制覆盖子布局对象自身声明的变换参数。引擎完全依据 DATA 数组进行重定位,忽略源对象的 POSITION。此前的沙漠关卡破洞缺陷正是因此导致:算法错误将某 mana_pit 放置于视口外的 $Z = -146$,而 DATA 中的 (118, 0, 170) 向量才是引擎实际将其纠正回有效区域的权威数据。差异缩减 2618 格,危险不可通行格进一步压降至 553
6Path Bounds Extender 原点修正类型为 19 的 Property Node 节点会被合并进网格的 Origin 坐标(但不纳入输出写入器的边界盒)。需追加校验门:仅当该节点实际扩展了栅格尺寸时才接纳其原点偏移,否则维持碰撞几何原生 Origin。网格尺寸匹配度增加 4 张,0 回归

此外,补全了水平间隙检测门:sub_100672B0 在执行三角形内命中测试前包含一个侧向边界测试(Side-test),此前由于代码复用遗漏该分支,补全后减少了 2021 处虚假墙体判定。

上述所有差异均有明确的静态对应物,完全可由源文本结合 Descriptor 描述符表完整解析推导。

TIP

工程方法论启示:当逆向实现中需要引入“基于名称模糊匹配”的启发式规则时,应优先深入逆向 DLL 底层对象判定门——官方实现通常已具备严谨的按对象属性过滤流程。采用“无差别遍历 + 严格属性过滤门”在代码鲁棒性与输出精度上均具备明显优势,且避免了维护繁复易错的人工黑白名单。

8.4 边界残差分析:三重烘焙对照实验

在修复上述六大规则缺口后,算法准确率已达到极高水平,但仍残留约 0.15% 的微小残差。从溯源统计看,导致分歧的几何体中有 68% 归属于树冠等带有 nocollide 材质标记的植被面片。直觉推导往往容易滑向不可知论:认为树冠受引擎风场驱动产生顶点摆动,摆动属于运行时动态计算,因而导致静态生成无法克服该浮点残差。

为验证该假设,设计了受控对照实验:驱动官方 DLL 对全量关卡(1116 张地图)独立执行 3 轮完整烘焙;离线确定性算法作为基准对照,观察各方在变动网格上的重合度。

实验统计数据(1116 张地图):

  • 官方 DLL 自身在 3 轮独立执行中,内部各轮之间的自歧义网格仅有 3826 格
  • 在存在分歧的网格中,离线判定为障碍而 DLL 判定为可通行的网格共 33,841 格,其中 DLL 在全部 3 轮中均判定为可通行的稳定网格达 33,329 格(占比 98.5%);真正呈现随机性非确定状态的网格仅有 512 格(1.5%);
  • 在反向分歧(离线判定可行走、DLL 判定为障碍)的 64,953 格中,确定性稳定网格占比同样高达 98.6%。

结论表明:残差绝非随机噪声,风场动态摆动假设被彻底排除

进一步实施 Mesh 归因裁决:为输入三角形挂载源 Mesh 标识,统计因特定 Mesh 导致判定分歧时官方原生的判定分布。统计表明:没有任何特定 Mesh 被官方引擎系统性地全量判定为可行走(各 Mesh 上的通行判定比率在 0% 至 4.6% 之间波动,加权平均仅 0.5%)。这表明试图通过“黑名单过滤某些特定 Mesh 类别”来拟合残差的思路同样无效——残差实质上是在空间所有倾斜 Mesh 表面上均匀分布的、比例约为 1% 的局部位置差异

8.5 底层根因剖析:微倾斜射线与浮点判据发散

深入追踪底层数学计算后,确认该残差源于三个耦合因素的叠加效应:

  1. 向下探测射线存在微小倾角:通过内存捕获 1324 个网格单元的射线参数,确认引擎在向下投射时,其方向向量并非标准垂直向量 $(0, -1, 0)$,而恒定存在微小倾斜量:(-5e-6, -1, +8.6e-5)
  2. 三角形内点测试依赖顶点法线:测试采用 Mesh 顶点预存的法线数据,而非动态计算的三点叉积:
    v8  = E1 · (N × E2)
    v16 = E3 · (N × E2)
    v15 = N · (E3 × E1)
    命中条件: v16 >= 0 && v8 >= v16 && v15 >= 0 && v8 >= v15 + v16
  3. 投影位移放大边缘阈值分歧:在倾斜碰撞三角表面上,沿 Y 轴下降 196 单位时,dz/dy = 8.6e-5 会在 Z 轴累积产生约 0.0167 的位移量。该漂移量足以改变处于临界边缘网格的分类结果(实测数据:原生环境计算值为 −0.00159 判定为未命中,标准垂直射线计算值为 +0.0275 判定为命中)。在水平平面三角(法向垂直)上无水平位移,两端判定完全一致——这合理解释了为何分歧仅集中在倾斜面边界。

若在离线实现中直接引入该倾斜向量及相同公式,孤立单三角形测试虽然达到了近乎完美的复刻(49,416 / 49,417 准确率),但在全图 A/B 测试中差异并未收敛(例如某张关卡差异数从 1805 变为 1817):其中 79 格由可通行翻转为墙体,70 格由墙体翻转为可通行,呈现对称波动。其根源在于官方引擎内部每个网格单元的坐标计算伴随 $\pm 0.001$ 级别的累积浮点微噪,而边界判定裕度本身仅在 $\pm 0.0017$ 量级。噪声与判据信号幅值相当,引入固定的微小倾角无法消除相干性误差。

结论:若追求 100% 逐字节完全一致,仅有两种工程解法:其一是在纯软件层面以 Bit-Exact 精度完全复刻 Ogre 1.7.4 的内部浮点计算管线(工程量巨大);其二是在桌面构建环境中直接调用官方原生 DLL 执行烘焙

同期被实证否决的四项优化假说:

  • QUEST / DIFFICULTY 字符串过滤:全语料遍历 35,376 个 Group 节点,实际配置相关字段的节点为 0,追加此项逻辑无实际效果。此外,“利用 GAME MODE 排除 NG+ 内容”的假说被实验否定——官方导出的包围盒实测大于基准网格,表明引擎在烘焙时包含了 NG+ 几何,予以排除反而偏离真实行为。
  • 全递归 Prop-to-Prop 链接展开:实测显示多层递归会产生净负收益(增加 191 处多余阻挡,仅消除 41 处穿透),单层链接限制系官方有意为之。
  • 局部常量转 float32:仅将计算常数改为 float32 属于半精度混用,在 float64 算术上下文中导致全语料净回归增加 318 处错误。若要彻底匹配,必须将全链路改为一致的 float32 运算。
  • NOCOLLIDE 严格区分大小写:尽管文档规范注明“材质名包含 NOCOLLIDE 子串”,但官方材质资产中有 296 处实际采用小写前缀 multi_collision/nocollide。若引入大小写敏感校验,将直接破坏大量地牢装饰的过滤逻辑。

8.6 算法可用性与工程落地指标

基于全量关卡(1116 张地图)对独立烘焙的 DLL 生产基准进行回归评测,最新指标如下:

评测指标测量结果
逐格可行走性准确率99.850%(全量 1116 张地图 / 91,704,677 个栅格单元)
危险误判(离线判定阻挡,DLL 判定可行走)34,615 格(0.038%
安全误判(离线判定可行走,DLL 判定阻挡)103,356 格(0.113%)
最大残留连通孤岛(Trap)80 格(彻底消除了 $\ge 100$ 格的卡死死区)
危险阻挡格为 0 的绝对安全地图数156 / 1116 张

工程维度的目标转换:虽然在纯数学层面追求 100% 逐字节镜像面临浮点精度极限,但在游戏体验维度,“确保路径连通、绝不困死玩家”是完全可达且更具实用价值的工程目标

为此在管线末端引入了 reconnect_walkable 连通性安全后处理通道:采用 0-1 BFS 广度优先搜索算法,识别并穿透那些“厚度仅为 1 格且割裂大型可行走区域”的假性薄墙阻隔(该优化默认启用)。该算法彻底消除了所有 $\ge 100$ 格的玩家受困陷阱。全语料中剩余 31 张存在极小残留孤岛的地图,经由 Ogre 3D 可视化渲染人工逐一核验,确认均属于紧贴碰撞几何边缘的狭小死角,无任何主干通路受阻情况。

生产实践选型建议:

  • 宿主具备完整 GUTS 环境且追求终极一致性:直接启用 --mpp dll 驱动官方原生动态链接库进行烘焙(产物逐字节对齐,全语料耗时约 25 分钟);
  • 追求高吞吐并最小化外部依赖:默认采用离线算法烘焙,仅对特定的复杂边界地图应用官方 DLL 补充覆盖;
  • 无 GUTS 环境或 WebAssembly 跨平台运行(如浏览器端):采用全离线算法,准确率达 99.85% 以上且内置防卡死拓扑修复。

WARNING

涉及地图瓦片(Tileset / Chunk)的 Mod 严禁跳过 MPP 生成环节。 Mod 源工程目录中通常不包含预编译的 .mpp 文件,若在打包流程中跳过该步骤,游戏引擎在缺乏基准 fallback 的情况下将无法构建可行走网格,导致角色进入自定义地图后完全丧失移动能力,复现类似 §8.2 中 2.5 KB Stub 网格的卡死现象。


9. 基础游戏数据资产解构:DATA.PAK 与三级 Base 源架构

关于基础游戏资源存储格式,社区中长期存在一个认知偏差:

官方安装目录下的 DATA.PAK 存储的并非明文源文本。 归档中的条目如 FOO.LAYOUT,其内部实际存储的是预先编译完成的 BINLAYOUT 二进制流(结构等价于开发工作区内的 .LAYOUT.BINLAYOUT 伴随文件)。MOD 开发者日常接触到的各类明文文本文件,实为早期社区解包工具(如 pakunpack)在提取过程中实施反编译反向还原的产物。

在存储格式上,DATA.PAK 的数据区与 .MOD 容器的数据区结构完全统一(起始偏移 0 处为 8 字节头部 [MaxCSize][Hash],后续紧跟若干 [u32 解压尺寸][u32 压缩尺寸][zlib 压缩块])。两者的唯一架构差异在于:DATA.PAK 的文件清单(Manifest)并未封装在文件尾部,而是以独立的伴随文件 DATA.PAK.MAN 形式存在。其二进制规范与 .MOD 的清单完全一致(版本号为 2、采用 MurmurHash64B 校验、根路径前缀为 'MEDIA/',并组织目录与文件元数据,详见社区参考实现 TL2Lib/rgpak.pas)。

明晰该底层机理后,现代离线打包器即可构建高度解耦的三级 Base 数据供给架构

本地散装 ./MEDIA 目录  >  从 ./PAKS/DATA.PAK 动态提取  >  工具内嵌的核心基础包 (gzip 约 3 MB)

其中最小化提取集涵盖:

  1. LEVELSETS 相关的 .DAT(用于提供 MPP 烘焙所需的 Piece 字典与 ROOMPIECES.RAW);
  2. UNITS 相关的 .DAT(提供 §7 中所述的 UNITDATA BASEFILE 属性继承链);
  3. 各 Piece 节点引用的碰撞 Mesh 几何模型。

上述资源由打包器按需提取并反编译为标准文本对象,缓存在工作目录供后续流水线调度。

三种数据源在编译同一关卡 Mod 时,输出成果均能达到 100% 逐字节完全一致(实测样本大小 414,110 字节):在二进制编译、RAW 索引构建(含 BASEFILE 继承解析)、MPP 网格烘焙及容器封包各阶段均具备等价确定性。内置反编译器对全部 34 个 LEVELSETS.DAT 及 4492 个 Piece 定义实现了无损 Round-Trip,与明文解包源完全对齐。

工程实践含义:构建流水线彻底解除了对游戏宿主客户端安装环境的强依赖,即使在完全未安装游戏的纯净机器乃至纯浏览器 WebAssembly 环境中,也能独立产出工业级 Mod 文件。


10. 工具链实现与性能评测

10.1 桌面端 Rust 编译器

生产级打包工具采用 Rust 构建。针对三项专有二进制格式(BINDAT、BINLAYOUT、MPP),均实现了真正的 Clean-room 从头编译管线:直接解析明文源文本并序列化为目标二进制,无需依赖任何现存二进制资产作为模板,彻底杜绝增量编译污染。

基准评测一:对比原生 GUTS 动态库流水线

为了实现严谨的横向对比,原生参考基准未采用带有前端 UI 渲染开销的 GUTS 编辑器,而是构建了一个专门的无头(Headless)测试宿主,直接驱动官方 EditorGuts.dll 的原生导出接口(调用 CreateModEditorRegenPathingData)。同时,将 InitEditor 单次 3.85 秒的冷启动耗时进行摊销剔除,仅衡量热构建(Warm Build)阶段的稳态吞吐。这代表了官方原生实现所能达到的性能上限。Rust 编译器在完全一致的源工程副本上执行构建,连续测试 5 轮并取算术平均值:

组件名称源文件数原生 Build (s)原生 MPP (s)原生合计 (s)Rust 5 轮耗时 (s)Rust 均值 (s)加速比
通用素材0112712164.05208.77372.826.46 / 11.75 / 12.37 / 6.25 / 6.058.5843.5×
职业技能68217268.420.00268.4212.14 / 19.55 / 8.85 / 9.30 / 11.0512.1822.0×
群魔堕落52438169.360.51169.8712.79 / 7.56 / 7.66 / 8.09 / 16.5810.5416.1×
暗黑传奇3202097.866.02103.889.46 / 5.21 / 5.36 / 9.76 / 10.187.9913.0×
暗黑世界3549.6817.2626.940.89 / 0.76 / 2.10 / 2.30 / 1.731.5617.3×
佣兵系统295614.160.8615.021.09 / 0.79 / 1.02 / 2.48 / 2.471.579.6×
至尊适配181811.140.0011.140.82 / 0.56 / 0.57 / 1.23 / 1.310.9012.4×
实验内容4708.310.008.310.65 / 0.47 / 0.47 / 1.07 / 1.060.7411.2×
护身符13484.290.004.290.48 / 0.36 / 0.96 / 0.99 / 0.330.626.9×
宠物系统4311.960.172.130.51 / 0.43 / 1.83 / 1.76 / 0.380.982.2×
合计172764749.2233.6982.8 s45.66 s21.5×

耗时对比:官方原生流水线耗时逾 16 分钟(982.8 秒),而 Rust 仅需 45.66 秒,整体吞吐提升达 21.5 倍(按各模块最优值合并计算达 31.0 秒,加速比 31.7 倍)。测试波动主要源于 Windows Defender 实时文件扫描与磁盘写入缓存刷写延迟(每轮测试需落盘约 600 MB 的 .MOD 资产)。

评测统计口径说明:

  • MPP 算法模型差异:原生流水线调用的是 EditorRegenPathingData(逐字节精度);Rust 默认采用离线逆向算法后端(§8.6 所述的 99.850% 精度)。若要达成 100% 逐字节对齐,需追加 --mpp dll 参数——此时两者调用完全相同的动态链接库,耗时与“原生 MPP”列完全等同。因此,在 21.5 倍的综合提速中,MPP 模块属于不同算法模型的性能置换;而在 BINDAT 编译、RAW 索引生成与容器封包等严格对齐的纯编译环节,性能优势呈现为原生 749.2 秒 vs Rust 包含 MPP 在内全流程仅 45.66 秒
  • 测试集文件规模差异:基准测试样本目录中包含了历史编译伴随文件(.BINDAT / .BINLAYOUT),遍历扫描开销略高于纯净源工程。此外,另有两个历史模块(地图扩展 242.98 秒、POE 52.72 秒)因源副本缺失未计入最终合计;若将两模块纳入,官方原生累计耗时将进一步拉长。

基准评测二:对比高度优化的 Python 参考实现

对比的 Python 参考实现已集成了多项深度优化:引入 isal 库实现 SIMD 加速压缩、利用 numba 对 MPP 几何计算实施 JIT 编译,并采用多进程结合线程池并发。测试基于 Monorepo 纯净源树执行(涵盖 10 个 Mod 组件,运行于 16 核平台,产物解压校验逐文件一致):

管线方案Python (isal + numba + 多进程)Rust 编译器加速比
全系列累计63.9 s23.7 s2.69×
暗黑世界5.07 s0.64 s7.9×
通用素材01 (263 MB)18.14 s5.16 s3.5×

综合三大技术方案的构建耗时横向对比:官方 GUTS 原生 $\approx 983\text{ s}$ / 深度优化 Python $\approx 64\text{ s}$ / Rust 生产级编译器 $\approx 46\text{ s}$。虽然测试环境与源样本因历史数据存在微小口径差异(983 秒与 46 秒基于历史复测集,64 秒基于纯净 Monorepo),但整体性能梯队十分清晰:Rust 实现不仅相比官方原生工具展现出数量级维度的性能跃迁,即使对比充分优化的 JIT/SIMD Python 方案,构建速率依然保持 2 倍以上的显著优势。

NOTE

二进制比对口径:不同构建工具产出的 .MOD 文件在二进制上的唯一预期差异在于 zlib 压缩数据流(由于不同压缩库实现的哈夫曼树与滑动窗口策略存在细微差异;游戏运行时仅调用 inflate 解压,解压语义完全透明等价)。因此,自动化回归比对必须在解压后对各文件内容实施校验,直接比对未解压容器会导致误报。

MPP 几何内核的优化历程:通过引入三角面片 AABB 预先剪裁、跨地图关卡布局缓存、基于 CSR(Compressed Sparse Row)格式的密集分桶栅格以及更换高性能全局内存分配器(Allocator),使离线烘焙耗时由 9.8 秒压降至 5.5 秒,且每项变更均通过全语料逐字节回归校验。同时,部分经验假设被实测推翻:例如尝试在顶点收集阶段预先完成坐标变换计算,实测因频繁内存分配与离散访存开销超过了 6 倍冗余矩阵乘法的计算成本,呈现净性能衰减,因而被彻底回退。

10.2 WebAssembly 浏览器运行时

借助 Rust 统一的交叉编译支持,核心编译器无缝编译为 wasm32-wasip1 目标,构成了站点内置的 网页版 .MOD 打包器:在浏览器中直接选取本地目录即可执行打包并下载,具备纯本地运行、零服务端数据上传与免安装的特性。

跨端架构的工程铁律——核心代码绝对复用:WASM Crate 通过 #[path] 机制只读引入桌面端已验证的打包器与编译器源代码(维持单一真实来源 Single Source of Truth),并通过同签名 Shim Crate 替换底层 C / OS 强相关依赖(例如引入纯 Rust 实现的压缩库、为本地注册表查询接口打桩注入虚拟值,并将针对 EditorGuts.dll 的原生交互接口替换为同签名的空操作 Stub)。

跨端一致性校验:在 1 MB 的宠物 Mod 样本上,桌面端与 WASM 端打包产物的 SHA256 完全一致;在 264 MB 的大型综合素材包解压对账中,非 MPP 文件的解压内容实现了 11035/11035 逐字节完全吻合

突破 32 位 WASM 4 GB 线性内存瓶颈:

桌面端采用全量内存物化并发策略:所有未压缩源数据、压缩块以及最终输出缓冲区同时常驻内存。当处理 786 MB 的原始未压缩资产时,在 WASM32 架构下(由于线性内存存在 4 GB 上限且分配后无法归还给操作系统),极易触发 OOM 崩溃。

为此设计了三阶段临时流式架构(Three-Pass Streaming):

  • Pass 1(文件流式压缩与暂存):逐个读取源文件 $\rightarrow$ 内存中即时压缩/直存 $\rightarrow$ 追加写入临时文件 <out>.data.tmp(内存中仅暂存单个文件缓冲区),同步在内存清单中记录偏移元数据与单块最大压缩尺寸;
  • Pass 2(免常驻计算 rollingHash):利用 §2.5 中分析得出的核心特征(滚动哈希仅在全包范围内离散抽样约 50 个字节),通过约 50 次 Seek 定位直接在临时文件上完成计算,无需将数百兆数据重新加载进内存;
  • Pass 3(最终镜像拼装):顺序写出 Header $\rightarrow$ [MaxCSize][rollingHash] 头部标记 $\rightarrow$ 分块流式拷贝临时文件数据段 $\rightarrow$ 写入 Manifest 清单段。

该架构将核心数据流沉降至本地磁盘或 WASI Shim 的宿主 JS 堆内存中(避开 WASM 线性内存空间),峰值内存开销被严格压降至单个文件的内存占用量。实测 264 MB 素材包在 111 秒内构建完成,全程无内存溢出,最终文件 SHA256 与桌面端完全对齐。

WASI 运行时环境踩坑记录:Node.js 下的 uvwasi 早期实现缺失对 fd_readdir 的有效支持(抛出 OS Error 52 异常),导致目录遍历读取结果为空,生成空 Mod 包。因此本地集成测试需基于 wasmtime 执行;而浏览器端采用的 @bjorn3/browser_wasi_shim 已完整实现 readdir 规范,生产环境完全不受该缺陷影响。

10.3 极简终端交互界面(TUI)

在无命令行参数直接启动打包器时,程序会自动激活基于终端的交互式界面(TUI):自动扫描当前执行目录及 ./mods/* 子目录,识别包含 MOD.DAT 的目录作为有效 Mod 源码包,检测含有 MEDIA/LAYOUTS/*.LAYOUT 的工程并标注为关卡地图类型。选中后即可一键打包并自动部署至系统的 Documents/My Games/Runic Games/Torchlight 2/mods 目录。

架构设计遵循最小侵入性原则:TUI 仅承担用户交互选择职责,选定参数后立即交还终端控制权,转由标准 Console 输出流驱动底层的编译与打包引擎——核心编译代码保持完全解耦。在非 TTY 交互环境(如自动化脚本管道、CI/CD 容器)中执行时,程序自动回退至 Usage 帮助文档输出,避免进程死锁挂起。


附录 A:关键符号与函数虚地址表 (EditorGuts.dll, ImageBase 0x10000000)

模块 / 功能虚地址 (Virtual Address)
InitEditor / CreateMod / EditorSetWorkingMod / EditorRegenPathingData0x10001DD0 / 0x100DE830 / 0x100E3B50 / 0x100DDDE0
打包编排 / 编译打包主体 / PrePacksub_103FA610 / sub_103F5DA0 / sub_103F50D0
MOD Header 序列化 / 反序列化sub_103F5DA0 / sub_103FA610
Manifest 序列化 / mhash 计算sub_102A5860 / sub_1028E6F0
PAK 数据段写出 (+ rollingHash 计算)sub_102A7100
打包排除黑名单 / 静态排除表sub_103F4340 / sub_11D44D40 @ unk_13E51C50
类型码 / 编译重映射 / 属性存储表sub_102A1EA0 / sub_102A24F0 / byte_11E94CD8
编译派发调度 (5 类明文源)sub_1029C9A0 (→ BINDAT sub_1028FC00 / BINLAYOUT sub_101169B0)
RAW 索引生成派发sub_1029BFA0
加载校验 / rollingHash 校验sub_103F83C0 / sub_102A3320sub_102A2690
reqHash / MurmurHash64B / gamever 读取sub_103F5500 / sub_10285330 / sub_103F8CD0
rollingHash 种子伪随机数生成 (LCG / 种子设置 / 状态暂存)sub_10285B30 / sub_10285A50 / sub_10285450
BINDAT 序列化 / 字符串收集 / Interner / 节点写 / WriteShortStringsub_10289A40 / sub_10289950 / sub_1023E9F0 / sub_10289860 / sub_1028ED40
BINLAYOUT 写入链 / 对象写出 / DataGroup / Tag 注册sub_101169B0… / sub_10115320 / sub_101150F0 / sub_10253630
RAW 索引构建: AFFIXES / SKILLS / MISSILES / UI / UNITDATAsub_103C4170 / sub_102ECFD0 / sub_102FB490 / sub_103178E0 / sub_1026CC50
MPP: RegenAll / RegenSingleFile / LoadLevelData / GenPathing / 写出器sub_10018750 / sub_10015FA0 / sub_1020AB90 / sub_10203710 / sub_10200920
MPP: 垂直探测 / Clearance 水平探测 / 三角内点测试 / 几何合并 / 装饰 Gatesub_101EF170 / sub_101EEEA0·sub_100672B0 / sub_10066E50 / sub_10068CB0 / sub_1022FF80
rg_hash 计算 (GUTS 侧 / 游戏侧)sub_100CA9A0 / sub_4C9FE0 (ImageBase 0x400000)

游戏运行时侧(Torchlight2.exe, ImageBase 0x400000): Scheme 解析 sub_7DEA10、资源管理器初始化 sub_64A590、路径 VFS 查找 sub_68F630、UNITDATA 读取 sub_660560(通过 sub_661480)。

附录 B:配套工具链与参考实现

  • 生产级桌面打包工具tl2-mikuro-mod-packer —— [--in-place|--temp-copy] [--mpp {re,dll,none}] [--raw {auto,none}] [--deploy] <mod目录>; 支持子命令 compile-dat / compile-layout / compile-mpp / extract-base / unpack-base;无参数启动默认进入交互式 TUI。
  • 纯客户端 WebAssembly 打包器/tools/packer/ —— 共享相同 Rust 编译内核,100% 浏览器客户端离线计算。
  • 诊断与控制环境变量
    • MIKURO_TIMING=1:输出细粒度流水线各阶段耗时统计;
    • MPP_TIMING=1:输出寻路网格各阶段几何处理与射线投射耗时;
    • MPP_RECONNECT=0:关闭网格连通性安全后处理(用于进行逐字节精准回归测试);
    • MIKURO_BINDAT_DICT:切换为基于全语料全局 ID 映射模式;
    • TL2_MEDIA_DIR / TL2_INSTALL_DIR / TL2_MOD_GAMEVER:覆盖默认路径与游戏版本检测。
  • 延伸阅读TL2 TAG 系统逆向 —— 详述本文 §5.2 涉及的 rg_hash 在游戏标签系统与属性键值解析上的体系架构。