接着之前 MPQ 格式解析那篇。格式读懂了只是第一步,这次要解决一个实际问题:手上有 3.3.5a 客户端的补丁归档 PATCH-I.MPQ(430MB)和一套完整的 DBC 数据(245 个文件),想把其中的 DBC 全部替换成自己的版本。听起来简单,动手才发现两个坎:这个补丁包没有 (listfile),一个文件名都看不到;而 MPQ 的块表重加密还有个不踩不知道的深坑。整理成这篇自包含笔记,工具是之前那篇的 mpq_parser.py(本次新增 replace 命令)。
1. 问题:为什么看不到文件树
先看现象。用解析器 list 这个补丁包:
1 | 共 10214 个文件块存在 (有名字 1, 未知 10213) |
10214 个文件,只有 (attributes) 一个有名字。原因在 MPQ 的底层设计(格式细节见 MPQ 格式解析那篇):
- 哈希表只存文件名的两个 32 位哈希(name1/name2),块表只有位置和大小——MPQ 从头到尾不存文件名原文;
- “枚举内容”完全依赖内嵌清单
(listfile),而 Blizzard 对 patch 归档刻意不放 listfile:客户端按硬编码路径哈希查找文件,根本不需要名字清单,省掉它还能减小补丁体积; - 唯一的指望
(attributes)也是坏的:解压后开头不是标准ATTR魔数,用标准密钥hash("(attributes)", FILE_KEY)和 FIX_KEY 变体都解不开,全文没有一段可读字符串——被加密混淆过,靠它恢复文件名没戏。
但数据本身是好的。抽查解压前几个块,全是正常的 BLP2 贴图和 zhCN 的 GlobalStrings.lua。看不到名字 ≠ 动不了文件。
2. 无名字归档怎么定位文件:标准路径哈希探测
客户端加载文件的链路是:
1 | 硬编码路径 "DBFilesClient\Spell.dbc" |
我们不知道归档里有什么,但知道客户端会按什么名字来找。把候选路径算出哈希去哈希表里查一下(解析器现成的 find_block_index),命中即定位:
1 | import sys; sys.path.insert(0, '.') |
注意路径规则与哈希函数一致:大小写不敏感、/ 与 \ 等价。DBC 在客户端里的标准路径前缀是 DBFilesClient\。
字典哪来?手头正好有一套完整客户端解包出的 data_v20\dbc(245 个 DBC)。拿这 245 个文件名逐个探测,命中 42 个——比例很合理:PATCH-I.MPQ 是补丁包,只收录被这个补丁修改过的 DBC,其余 203 个在基础包里,不在这个归档的管辖范围。
没有现成字典时,用社区维护的 known listfile(Ladik 等工具内置的那份)做碰撞即可,方法完全一样。
flags=0x80000200 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS:无加密、非单块、非 patch delta、无扇区 CRC,是最干净的替换目标。flags 里有 E(加密)、P(patch delta,需要 base 归档合成数据)的块,替换前要掂量。
3. 怎么确认拿到的真是 DBC:WDBC 头校验
替换前的最后一道关:确认解出来的东西真是格式完好的 DBC。DBC 格式那篇讲过 WDBC 结构,实战中看头 20 字节就够了:
1 | 偏移 大小 字段 SpellItemEnchantment.dbc 实测 |
两条快速自检:
- 总长守恒:
20 + RecordCount × RecordSize + StringBlockSize == 文件大小。不等说明文件截断或者根本不是 DBC; - RecordSize 与 FieldCount 的关系:
RecordSize == FieldCount × 4说明全表没有 1 字节字段(如本表);若不等,差值就是 1 字节字段的个数,解析时按uint32/uint8混合切。
提取命令一行搞定:
1 | python mpq_parser.py extract PATCH-I.MPQ "DBFilesClient\SpellItemEnchantment.dbc" SpellItemEnchantment.dbc |
4. 替换实现:尾部追加 + 块表单条目改写
4.1 为什么不重建归档
MPQ 归档里各文件的数据块相互独立,位置全靠块表描述。替换一个文件不需要碰其他任何数据:
1 | 新数据压缩 → 追加到归档尾部 (512 对齐) |
对比”解包 10214 个文件 → 重新打包”的重建方案:不用腾 430MB×2 的临时空间、没有遗漏改名风险、单文件替换几秒完成。代价是归档体积单调增长(旧数据成死空间),对补丁场景无所谓。
4.2 写入端布局必须与读取端对偶
读取端对 COMPRESS 多扇区文件的布局要求是死的:
1 | [扇区偏移表 (sectorCount+1) × uint32] ← offsets[0] = 表自身长度, 供校验 |
写入端照此生成:每扇区 zlib.compress(raw, 9) 前加 1 字节算法掩码 0x02;压缩后不比原文小的扇区直接存明文(读取端的判据是 raw_len == expected 即未压缩);不写扇区 CRC,同步清掉条目的 SECTOR_CRC 标志。
4.3 深坑:加密和解密不是同一个函数
这是本次最大的收获,值得单独一节。块表是整表加密的,改一条就得”解密→改→重新加密”。想当然认为 XOR 类加密正反同函数,直接把解密函数再跑一遍当加密用——结果整张块表报废,10214 个条目全部乱码(万幸是在测试副本上)。最小复现:
1 | p = decrypt(c0, key) # 解密 → 明文 ✓ |
翻 StormLib 源码(StormCommon.h)才知道两个方向的 key2 推进来源不同:
1 | // DecryptMpqBlock: key2 用"还原出的明文"(输出)推进 |
两个方向的 key2 都按”明文字”推进,但解密时明文是输出,加密时明文是输入。拿解密函数处理明文时输出是密文,key2 就按密文推进了——一步走偏,全表皆错。修复是老老实实写一个 encrypt_block:
1 | def encrypt_block(buf, key): |
修复后对真实归档整张 164736 字节的块表做”解密→加密”往返,与磁盘原文逐字节相等。教训:MPQ 的加密解密是一对镜像函数,不是同一个函数。
5. 批量替换实战:42 个 DBC 全量打进补丁包
5.1 先建映射表
对 245 个候选名批量探测,落一张 TSV 映射表(是否在包内、块索引、包内外大小、flags):
1 | for name in os.listdir(r'D:/games/wow-study/data_v20/dbc'): |
42 命中 / 203 不在包内。映射表还有个意外用途——版本对比:替换前 DungeonMapChunk.dbc 包内 87401 字节 vs data_v20 12461 字节,说明补丁里的版本比手头数据新得多;替换后统一拉平。
5.2 批量执行与验证
驱动脚本按映射表循环调用 replace(每次调用独立打开归档,改完即落盘),40 个文件(2 个之前已替换过)一次跑完,40/40 成功零失败,归档从 430,952,769 涨到 439,620,927 字节(追加约 8.7MB)。
最终验证三条:
- 逐字节回读比对:42 个 DBC 全部与 data_v20 版本
back == local为 True; - 邻居完好性:替换块前后相邻块、随机抽样块解压正常(
WDBC/BLP2魔数正确); - 结构一致性:文件块总数 10214 不变,头部 archive_size 与实际文件大小相等。
多次追加替换互不影响——每次替换只改写自己那条块表记录,前面的追加数据原封不动。
6. 操作手册
1 | # 定位 (标准路径哈希探测, flags 不应有 E/P/D) |
注意事项:
- 重启客户端生效,MPQ 启动时加载,运行中不重读;
- 改动没生效?检查是否有字母序更靠后的补丁 MPQ 里存在同名文件(加载顺序后者覆盖前者);
- 替换后
(attributes)里的记录(大小/CRC)会过期——客户端运行时不校验 attributes,只有第三方校验工具会抱怨; - v1 归档 4GB 上限:追加偏移是 32 位,反复大量替换要留意余量;
- 这套方法只能替换已有条目,不能新增文件——新增需要扩哈希表(容量恒为 2 的幂),等价于重建归档。
7. 小结
回头看,整条链路就是三句话:
- 没有文件树不影响动手——MPQ 的查找本来就是”路径哈希 → 块表”,拿客户端会用的标准路径反查即可,字典覆盖率决定能找到多少;
- DBC 识别看头 20 字节——
WDBC魔数加”20 + 记录数×记录大小 + 字符串块 = 文件总长”守恒校验,几秒钟排除假货; - 替换走”追加 + 改表”路线——不重建归档,代价最小;唯一的深坑是块表重加密必须用独立的加密函数,MPQ 的加密解密是一对镜像,不是同一把钥匙正反拧。
工具:mpq_parser.py(list / tree / extract / extractall / info / replace),纯 Python 标准库,无第三方依赖。最终交付的 PATCH-I.MPQ 经文件级验证:42 个 DBC 回读逐字节一致、归档结构完好;客户端实际加载效果待重启游戏确认。