Hello World

吞风吻雨葬落日 欺山赶海踏雪径

0%

WOW 3.3.5a MPQ 补丁归档 DBC 替换实战

接着之前 MPQ 格式解析那篇。格式读懂了只是第一步,这次要解决一个实际问题:手上有 3.3.5a 客户端的补丁归档 PATCH-I.MPQ(430MB)和一套完整的 DBC 数据(245 个文件),想把其中的 DBC 全部替换成自己的版本。听起来简单,动手才发现两个坎:这个补丁包没有 (listfile),一个文件名都看不到;而 MPQ 的块表重加密还有个不踩不知道的深坑。整理成这篇自包含笔记,工具是之前那篇的 mpq_parser.py(本次新增 replace 命令)。

1. 问题:为什么看不到文件树

先看现象。用解析器 list 这个补丁包:

1
2
3
4
5
6
共 10214 个文件块存在 (有名字 1, 未知 10213)
内置 (listfile) 提供文件名数: 1
Index CompSize UncompSize 文件名 Flags
357 0x000002F8BD 0x0000046628 (attributes) C
0 0x0000007C32 0x0000012941 (unknown #0) C
...

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
2
3
4
硬编码路径 "DBFilesClient\Spell.dbc"
│ hash_string(路径, NAME_A / NAME_B / TABLE_OFFSET)

哈希表线性探测 → block_index → 块表 → 数据

我们不知道归档里有什么,但知道客户端会按什么名字来找。把候选路径算出哈希去哈希表里查一下(解析器现成的 find_block_index),命中即定位:

1
2
3
4
5
6
7
import sys; sys.path.insert(0, '.')
from mpq_parser import MPQArchive, MPQ_FILE_EXISTS

with MPQArchive(r'D:/games/wow-study/PlayBots_Stuff/Temp/PATCH-I.MPQ') as arc:
idx = arc.find_block_index(r'DBFilesClient\SpellItemEnchantment.dbc')
b = arc.block_table[idx]
# 块 9968, comp=49297, uncomp=447066, flags=0x80000200

注意路径规则与哈希函数一致:大小写不敏感、/\ 等价。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
2
3
4
5
6
偏移  大小  字段             SpellItemEnchantment.dbc 实测
0 4 magic 'WDBC' ← 魔数, 一票否决
4 4 RecordCount 2656 ← 记录数
8 4 FieldCount 38 ← 每条记录字段数
12 4 RecordSize 152 ← 每条记录字节数
16 4 StringBlockSize 99756 ← 字符串块大小

两条快速自检:

  • 总长守恒20 + RecordCount × RecordSize + StringBlockSize == 文件大小。不等说明文件截断或者根本不是 DBC;
  • RecordSize 与 FieldCount 的关系RecordSize == FieldCount × 4 说明全表没有 1 字节字段(如本表);若不等,差值就是 1 字节字段的个数,解析时按 uint32/uint8 混合切。

提取命令一行搞定:

1
2
python mpq_parser.py extract PATCH-I.MPQ "DBFilesClient\SpellItemEnchantment.dbc" SpellItemEnchantment.dbc
# 提取成功: SpellItemEnchantment.dbc (447066 bytes) → 魔数 b'WDBC' ✓

4. 替换实现:尾部追加 + 块表单条目改写

4.1 为什么不重建归档

MPQ 归档里各文件的数据块相互独立,位置全靠块表描述。替换一个文件不需要碰其他任何数据:

1
2
3
新数据压缩 → 追加到归档尾部 (512 对齐)
块表解密 → 只改这一个条目 (file_pos/comp_size/uncomp_size) → 重新加密写回
哈希表不动 (名字哈希没变) · 头部只更新 archive_size

对比”解包 10214 个文件 → 重新打包”的重建方案:不用腾 430MB×2 的临时空间、没有遗漏改名风险、单文件替换几秒完成。代价是归档体积单调增长(旧数据成死空间),对补丁场景无所谓。

4.2 写入端布局必须与读取端对偶

读取端对 COMPRESS 多扇区文件的布局要求是死的:

1
2
[扇区偏移表 (sectorCount+1) × uint32]   ← offsets[0] = 表自身长度, 供校验
[扇区0][扇区1]... ← 每扇区 ≤ sectorSize (本包 4096)

写入端照此生成:每扇区 zlib.compress(raw, 9) 前加 1 字节算法掩码 0x02压缩后不比原文小的扇区直接存明文(读取端的判据是 raw_len == expected 即未压缩);不写扇区 CRC,同步清掉条目的 SECTOR_CRC 标志。

4.3 深坑:加密和解密不是同一个函数

这是本次最大的收获,值得单独一节。块表是整表加密的,改一条就得”解密→改→重新加密”。想当然认为 XOR 类加密正反同函数,直接把解密函数再跑一遍当加密用——结果整张块表报废,10214 个条目全部乱码(万幸是在测试副本上)。最小复现:

1
2
3
p  = decrypt(c0, key)   # 解密 → 明文 ✓
c1 = decrypt(p, key) # 再跑一次想当加密用
c1 == c0 # False ! 整表已坏

翻 StormLib 源码(StormCommon.h)才知道两个方向的 key2 推进来源不同:

1
2
3
4
5
6
7
8
9
// DecryptMpqBlock: key2 用"还原出的明文"(输出)推进
dwValue32 = *DataBlock ^ (dwKey1 + dwKey2); // 输入异或 → 明文
*DataBlock = dwValue32;
dwKey2 = dwValue32 + dwKey2 + (dwKey2 << 5) + 3;

// EncryptMpqBlock: key2 用"输入的明文"推进
dwValue32 = *DataBlock; // 明文是输入
*DataBlock = dwValue32 ^ (dwKey1 + dwKey2);
dwKey2 = dwValue32 + dwKey2 + (dwKey2 << 5) + 3;

两个方向的 key2 都按”明文字”推进,但解密时明文是输出,加密时明文是输入。拿解密函数处理明文时输出是密文,key2 就按密文推进了——一步走偏,全表皆错。修复是老老实实写一个 encrypt_block

1
2
3
4
5
6
7
8
def encrypt_block(buf, key):
for i in range(nwords):
key2 = (key2 + crypt[0x400 + (key1 & 0xFF)]) & 0xFFFFFFFF
plain = struct.unpack_from('<I', buf, i * 4)[0] # 明文 = 输入
struct.pack_into('<I', buf, i * 4, plain ^ ((key1 + key2) & 0xFFFFFFFF))
key1 = (((~key1 << 21) & 0xFFFFFFFF) + 0x11111111) & 0xFFFFFFFF | (key1 >> 11)
key2 = (plain + key2 + (key2 << 5) + 3) & 0xFFFFFFFF # 按明文推进
return key

修复后对真实归档整张 164736 字节的块表做”解密→加密”往返,与磁盘原文逐字节相等。教训:MPQ 的加密解密是一对镜像函数,不是同一个函数。

5. 批量替换实战:42 个 DBC 全量打进补丁包

5.1 先建映射表

对 245 个候选名批量探测,落一张 TSV 映射表(是否在包内、块索引、包内外大小、flags):

1
2
3
for name in os.listdir(r'D:/games/wow-study/data_v20/dbc'):
idx = arc.find_block_index('DBFilesClient\\' + name)
# 命中: 记录块索引与大小; 未命中: 标记 NO

42 命中 / 203 不在包内。映射表还有个意外用途——版本对比:替换前 DungeonMapChunk.dbc 包内 87401 字节 vs data_v20 12461 字节,说明补丁里的版本比手头数据新得多;替换后统一拉平。

5.2 批量执行与验证

驱动脚本按映射表循环调用 replace(每次调用独立打开归档,改完即落盘),40 个文件(2 个之前已替换过)一次跑完,40/40 成功零失败,归档从 430,952,769 涨到 439,620,927 字节(追加约 8.7MB)。

最终验证三条:

  1. 逐字节回读比对:42 个 DBC 全部与 data_v20 版本 back == local 为 True;
  2. 邻居完好性:替换块前后相邻块、随机抽样块解压正常(WDBC/BLP2 魔数正确);
  3. 结构一致性:文件块总数 10214 不变,头部 archive_size 与实际文件大小相等。

多次追加替换互不影响——每次替换只改写自己那条块表记录,前面的追加数据原封不动。

6. 操作手册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 定位 (标准路径哈希探测, flags 不应有 E/P/D)
python mpq_parser.py info <MPQ> # 看头部与块表

# 提取 → 确认 WDBC 头 → 修改
python mpq_parser.py extract <MPQ> "DBFilesClient\Spell.dbc" Spell.dbc.orig

# 备份 (替换不可逆!)
cp <MPQ> <MPQ>.bak

# 替换 (新数据尾部追加, 不重建归档)
python mpq_parser.py replace <MPQ> "DBFilesClient\Spell.dbc" <新文件.dbc>

# 验证 (回读比对 + 邻居抽查)
python mpq_parser.py list <MPQ>

注意事项:

  • 重启客户端生效,MPQ 启动时加载,运行中不重读;
  • 改动没生效?检查是否有字母序更靠后的补丁 MPQ 里存在同名文件(加载顺序后者覆盖前者);
  • 替换后 (attributes) 里的记录(大小/CRC)会过期——客户端运行时不校验 attributes,只有第三方校验工具会抱怨;
  • v1 归档 4GB 上限:追加偏移是 32 位,反复大量替换要留意余量;
  • 这套方法只能替换已有条目,不能新增文件——新增需要扩哈希表(容量恒为 2 的幂),等价于重建归档。

7. 小结

回头看,整条链路就是三句话:

  1. 没有文件树不影响动手——MPQ 的查找本来就是”路径哈希 → 块表”,拿客户端会用的标准路径反查即可,字典覆盖率决定能找到多少;
  2. DBC 识别看头 20 字节——WDBC 魔数加”20 + 记录数×记录大小 + 字符串块 = 文件总长”守恒校验,几秒钟排除假货;
  3. 替换走”追加 + 改表”路线——不重建归档,代价最小;唯一的深坑是块表重加密必须用独立的加密函数,MPQ 的加密解密是一对镜像,不是同一把钥匙正反拧。

工具:mpq_parser.py(list / tree / extract / extractall / info / replace),纯 Python 标准库,无第三方依赖。最终交付的 PATCH-I.MPQ 经文件级验证:42 个 DBC 回读逐字节一致、归档结构完好;客户端实际加载效果待重启游戏确认。