最近在给 AzerothCore 私服做图标提取,翻到了 patch-zhCN-Y.MPQ 这个简体中文补丁包。MPQ 是暴雪从 Diablo 时代用到 WoW 6.0 的归档容器,网上资料不少但大多是抄 StormLib 源码,很少讲清楚”为什么要这么设计”。顺着 warcrafty(纯 Dart 实现)和一份自写的 Python 脚本 mpq_parser.py,把整个格式从字节层面捋了一遍,整理成这篇自包含的完整笔记——读完它不需要再去翻任何外部文档,就能从零读懂并动手实现一个 MPQ 读取器。
1. MPQ 是什么
MPQ(Mo’PaQ,”Mike O’Brien Pack”)是暴雪自有归档容器,承担客户端”数据压缩包”角色。一个 *.MPQ 里能塞任意多个游戏资产——模型、贴图、DBC 表、BLP 图标、声音、配置文本——并提供:
- 按文件名哈希索引:不存原文文件名,靠两个 32 位哈希定位
- 多压缩编码可选:zlib / PKWARE DCL / bzip2 / sparse,可叠加
- 可选加密:分块(扇区)级 32 位对称加密
- 增量补丁链:
patch-x.MPQ优先于base.MPQ,还能声明”删除某文件” - 扩展性:v1(≤4GB)、v2(64 位偏移 + hi-block 表)、v3/v4(HET/BET 表,已被 CASC 取代)
本文覆盖 v1 与 v2——3.3.5a 客户端用的就是这两代。
2. 文件整体布局
1 | ┌───────────────────────────────────────────────┐ |
核心是两张表:
- 哈希表:把文件名(经哈希)映射到块表索引。开放寻址线性探测,容量恒为 2 的幂。
- 块表:把块索引映射到「文件数据在归档里的字节范围 + 标志位」。
关键设计后果:MPQ 不存储文件名原文。所以”枚举归档内容”完全依赖一个内嵌的清单文件 (listfile)——它本身也是归档里的一个文件,内容是 \r\n 分隔的文件名列表。没有它就只能按已知名精确提取,无法列举。
3. 加密与哈希原语
所有运算都是 32 位无符号——用 64 位 int 实现时每一步都要 & 0xFFFFFFFF 截断。
3.1 加密表(storm buffer)
一张 0x500(=1280)个 32 位字的预生成表,所有加密/哈希都查它。算法是固定种子的线性同余递推:
1 | def build_storm_buffer(): |
易错点:填表是按”列”散布的(buf[0], buf[0x100], buf[0x200], buf[0x300], buf[0x400] 一组,再 buf[1], buf[0x101], …),不是顺序写。初值 0x00100001 和模数 0x2AAAAB 必须严格复刻,差一位结果全错。表只用构建一次,全局缓存。
3.2 字符串哈希(hashString)
文件名变换后查 storm buffer。5 种类型对应 buffer 内 5 个段:
| 类型常量 | 段起始 | 用途 |
|---|---|---|
tableIndex=0x000 |
0x000–0x0FF | 求哈希表起始槽位 |
nameA=0x100 |
0x100–0x1FF | 文件名校验哈希 A |
nameB=0x200 |
0x200–0x2FF | 文件名校验哈希 B |
fileKey=0x300 |
0x300–0x3FF | 文件加密密钥 |
key2Mix=0x400 |
0x400–0x4FF | 加密时混入推进的查表位置 |
1 | def hash_string(name: bytes, type_: int) -> int: |
to_upper 是一张定表——只把 / 换成 \、小写字母换大写,不改其他字节(含 0x80 以上的非 ASCII)。文件名大小写不敏感、正反斜杠等价。
3.3 块加密(encrypt/decryptBlock)
固定密钥的 32 位分组对称加密,原地操作,处理单位是 32 位小端字,末尾不足 4 字节的部分原样不动。
1 | def decrypt_block(data: bytearray, key: int) -> bytearray: |
encryptBlock 对称:把 plain = read ^ (key1+key2) 改成 cipher = plain ^ (key1+key2),其余一致——两个方向都用明文推进 key2。
3.4 文件密钥推导(fileKey)
加密文件用密钥由文件名末段推导,可选按位置/大小修正:
1 | key = hashString(plainName(name), 0x300) |
plainName 取最后一个 \ 或 / 之后的部分。blockOffset 是文件数据相对归档起始的偏移(即块表里的 offset 字段)。
3.5 两张表的全局密钥
整张哈希表 / 块表在归档里用固定密钥加密,与具体文件无关:
1 | HASH_TABLE_KEY = hashString('(hash table)', 0x300) # == 0xC3AF3770 |
4. 头部定位与解析(操作 ①)
4.1 魔数
1 | MPQ_SIGNATURE = 0x1A51504D # 'M','P','Q','\x1A' |
4.2 头部定位算法
归档头不一定从文件开头起——前面可能有自解压壳或用户数据块。按 512 字节边界扫描:
1 | def locate_header(data: bytes) -> int: |
注意扫描步长是 512,不是 1 或 4。原版工具按扇区对齐写盘,扫到第一个魔数即可。
4.3 头部字段布局
v1 头 32 字节,v2 头 44 字节,全部小端:
| 偏移 | 长度 | 字段 | v1 | v2 | 备注 |
|---|---|---|---|---|---|
| 0 | 4 | dwSignature | ✓ | ✓ | 0x1A51504D |
| 4 | 4 | dwHeaderSize | ✓ | ✓ | v1=0x20,v2=0x2C |
| 8 | 4 | dwArchiveSize | ✓ | ✓ | v1 32 位,常不准,以实际文件长度为准 |
| 12 | 2 | wFormatVersion | ✓ | ✓ | 0=v1,1=v2(不是 1/2) |
| 14 | 2 | wSectorSizeShift | ✓ | ✓ | 扇区大小 = 512 << shift,典型 3(→4096) |
| 16 | 4 | dwHashTablePos | ✓ | ✓ | 哈希表偏移(低 32 位) |
| 20 | 4 | dwBlockTablePos | ✓ | ✓ | 块表偏移(低 32 位) |
| 24 | 4 | dwHashTableSize | ✓ | ✓ | 哈希表条目数,必为 2 的幂 |
| 28 | 4 | dwBlockTableSize | ✓ | ✓ | 块表条目数 |
| 32 | 8 | HiBlockTablePos | — | ✓ | uint64,hi-block 表偏移 |
| 40 | 2 | wHashTablePosHi | — | ✓ | 哈希表偏移的高 16 位 |
| 42 | 2 | wBlockTablePosHi | — | ✓ | 块表偏移的高 16 位 |
4.4 合并高位(仅 v2)
1 | if version >= 1 and len(header) >= 44: |
4.5 三道校验
formatVersion ∈ {0,1}(>1 是 v3/v4,超出范围)hashTableSize > 0且是 2 的幂:(size & (size-1)) == 0sectorSizeShift <= 23(扇区大小不超过 512 << 23 ≈ 4GB)
4.6 派生量
1 | archiveOffset # 头在文件中的绝对偏移 |
所有”相对偏移”都要加上 archiveOffset 才是文件里的真实位置。
5. 哈希表与块表
5.1 哈希表条目(16 字节)
| 偏移 | 长度 | 字段 | 备注 |
|---|---|---|---|
| 0 | 4 | dwNameA | hashString(name, 0x100) |
| 4 | 4 | dwNameB | hashString(name, 0x200) |
| 8 | 2 | wLocale | 语言 ID,0 = 中性 |
| 10 | 1 | wPlatform | 实践中总是 0 |
| 11 | 1 | bReserved | 原样保留 |
| 12 | 4 | dwBlockIndex | 块表索引,或特殊值 |
特殊 dwBlockIndex:
1 | FREE = 0xFFFFFFFF # 从未用过;查找遇到它即终止 |
整张表落盘时整体加密一次,密钥 = HASH_TABLE_KEY。读取时把 hashTableSize × 16 字节整块读出,调一次 decryptBlock(bytes, HASH_TABLE_KEY),再按 16 字节切分。
5.2 哈希表查找(开放寻址线性探测)
1 | def lookup(hash_table, name: str, locale=0): |
命中判据:双哈希 nameA == nameB 同时相等。locale:精确匹配优先,其次语言中性(locale=0),最后任意同名。
为什么 FREE 终止而 DELETED 继续:删除时不能把槽位标 FREE,否则会截断探测链——后续同哈希的文件就再也查不到了。
5.3 块表条目(16 字节)
| 偏移 | 长度 | 字段 | 备注 |
|---|---|---|---|
| 0 | 4 | dwFilePosition | 文件数据相对 archiveOffset 的偏移(低 32 位) |
| 4 | 4 | dwCSize | 压缩后字节数 |
| 8 | 4 | dwFSize | 解压后字节数 |
| 12 | 4 | dwFlags | 文件标志位 |
读取与哈希表一致:整块读、调 decryptBlock(bytes, BLOCK_TABLE_KEY)、按 16 字节切分。v2 还要读 hi-block 表(每条 2 字节,不加密),与 dwFilePosition 合并出 48 位偏移:
1 | position = dwFilePosition | (hiBlockTable[i] << 32) |
5.4 文件标志位(dwFlags)
1 | IMPLODE = 0x00000100 # PKWARE DCL;扇区内没有掩码字节 |
6. 列出归档内容(操作 ②)
MPQ 不存原文文件名——哈希表只存 nameA/nameB,不可逆。**枚举归档完全依赖内嵌清单文件 (listfile)**(名字带括号、全小写)。
1 | def list_files(archive) -> list[str]: |
易踩坑:
- 清单可能引用已不存在的文件(条目被删但清单未更新)——用
contains(name)过滤 - StormLib 只按
\r\n切且把空格当名字一部分;宽松解析(兼容\n/;/ trim)对真实归档几乎无副作用 contains(name)内部就是查哈希表 → 块表条目的EXISTS位
7. 提取单个文件(操作 ③)
最复杂的一步。完整流程:
1 | def extract(archive, name: str) -> bytes: |
7.1 单块文件(SINGLE_UNIT)
整个文件作为一个块,没有扇区偏移表:
1 | def read_single_unit(f, block, data_offset, key) -> bytes: |
7.2 多扇区文件(默认形态)
文件按 sectorSize(典型 4096)切成 N 个扇区,独立压缩/加密。数据开头是一张扇区偏移表(仅压缩时存在)。
7.2.1 取扇区偏移表
1 | def sector_offsets(f, block, data_offset, key, sector_count, sector_size) -> list: |
关键常量:偏移表的加密密钥是 (fileKey - 1) & 0xFFFFFFFF——不是 fileKey 本身。这是 StormLib 的固定约定,搞错就读出一串废数据。
附加校验:偏移表第一项 offsets[0] 恒等于表自身长度 entryCount * 4。
7.2.2 逐扇区读取
1 | def read_sectors(f, header, block, data_offset, key) -> bytes: |
两个不可忽视的细节:
- 每扇区密钥:
(key + i) & 0xFFFFFFFF——扇区索引参与密钥。偏移表是(key - 1),扇区是(key + i)。 - “是否真压缩”判据:
rawLen < expected。压缩侧若发现某扇区压缩后反而变大,会退回存原始字节(”压缩没有收益”)——读取侧必须用同一判据,否则会把明文当压缩流去解,失败。
7.3 decompressByFlags 派发
1 | def decompress_by_flags(raw: bytes, expected: int, flags: int) -> bytes: |
7.4 扇区校验(可选,建议忽略)
SECTOR_CRC 标志时,文件数据末尾跟一个 sectorCount × 4 字节的 adler32 数组。实战建议:默认不校验——真实归档里这些值经常为空或不规范,开了校验会把别的工具读得通的文件判损坏。StormLib 默认也是关的,要显式传 MPQ_OPEN_CHECK_SECTOR_CRC 才开。
8. 压缩方法派发
8.1 方法掩码(首字节)
| 位 | 值 | 方法 | 备注 |
|---|---|---|---|
| 0 | 0x01 | Huffman | 仅 WAVE 音频 |
| 1 | 0x02 | zlib(deflate) | 最常见 |
| 3 | 0x08 | PKWARE DCL | 又名 implode |
| 4 | 0x10 | bzip2 | War3 起 |
| 5 | 0x20 | sparse | SC2 起,游程编码 |
| 6/7 | 0x40/0x80 | IMA ADPCM | 仅音频 |
8.2 多方法组合的顺序
掩码可叠加(如 0x22 = sparse + zlib)。应用顺序固定,与位序无关,是 StormLib SCompression.cpp 里写死的 dcmp_table:
1 | 解压顺序(按表序,循环检查每一位): |
为什么 sparse 在最后:sparse 是 LZ77 风格的零填充游程,输出可能给后续解压”喂”长度修正过的缓冲区。压缩侧的顺序正好相反(先 sparse 再 zlib),保证解压侧按 dcmp_table 还原回原文。
8.3 adler32
MPQ 的扇区校验用 adler-32,初值是 0(标准 RFC 2920 的初值是 1):
1 | def adler32(data: bytes, seed: int = 0) -> int: |
别用标准库默认初值 1——很多库默认初值 1,要么手写,要么传 seed=0。
8.4 各编码器实现要点
- zlib:MPQ 里是完整 zlib 流(带 2 字节 header 与 ADLER32 trailer),标准库
zlib.decompress直接解。 - bzip2:完整 bzip2 流(
BZh+ 级别 + 块),标准库bz2.decompress即可。注意解压上限——恶意流能声明 9 级块累计撑到 GB 级。 - PKWARE DCL:没有标准库,90 年代 PKWARE 商业压缩库的 LZ77 变体。需要位流最低位优先、字面量/长度-距离对、两套固定 Huffman 码表。码表常量直接抄 StormLib
pklib/explode.c,手写约 200 行。只读不写可省掉编码器。 - sparse:最简单,自己实现 30 行。长度头是 4 字节大端(与 MPQ 其余部分相反),控制字节最高位置 1 表示后跟
(b&0x7F)+1字节原样数据,置 0 表示填充(b&0x7F)+3个零。
9. 提取全部文件(操作 ④)
操作 ② + ③ 的简单组合:
1 | def extract_all(archive, out_dir): |
内存考虑:extract() 一次性把文件读进内存。对几十 MB 的模型/贴图桌面工具一般可接受;大文件(>100MB)建议流式逐扇区写盘。
10. 边角情况速查
| 情况 | 处理 |
|---|---|
文件 fileSize == 0 |
空文件,直接返回 b"",不读扇区 |
isSingleUnit |
无扇区偏移表;加密时密钥不掺扇区索引 |
hasKeyV2 |
密钥 = (hash + offset) ^ fileSize,否则 = hash |
| 偏移表加密 | 用 (fileKey - 1) & 0xFFFFFFFF,不是 fileKey |
| 扇区加密 | 第 i 个扇区用 (fileKey + i) & 0xFFFFFFFF |
| 压缩无收益 | 扇区退回存原字节;读取侧靠 rawLen < expected 判定 |
| IMPLODE vs COMPRESS | IMPLODE 无掩码字节;COMPRESS 首字节是掩码 |
用户数据块(MPQ\x1B) |
从偏移 +8 读 headerOffset 跳过去 |
| 归档起始不在 0 | 按 512 边界扫描;所有相对偏移都相对 archiveOffset |
| v2 hi-block 表 | 16 位/条,不加密;块偏移 = pos | (hi[i] << 32) |
| 补丁文件 | 起始有 TPatchInfo;需要补丁归档链处理 |
11. 为什么客户端加载加密的 MPQ 不需要密钥
这是被问得最多的问题。MPQ 的”加密”分三层,目的完全不同:
| 层 | 对象 | 密钥 | 目的 |
|---|---|---|---|
| 表级加密 | 哈希表、块表 | 固定常量 | 混淆,不是保密 |
| 文件级加密 | 单个文件(ENCRYPTED 标志) |
文件名哈希派生 fileKey |
防篡改/防直接搜字符串 |
| 归档级加密 | 整个归档 | 外部密钥 MPQ_OPEN_KEY |
真正的 DRM,但 WoW 几乎不用 |
前两层都不需要外部密钥,因为密钥是”算法自带、可由已知信息推导”的:
- 表级密钥就是把
(hash table)/(block table)这两个字符串哈希出来,任何实现 MPQ 格式的人都知道 - 文件级密钥由文件名推导,而文件名来自
(listfile)清单或用户输入
所以客户端加载时全程没有”密钥文件”或”用户密码”——它就是混淆(obfuscation),不是真正的加密(encryption)。挡的是”不知道文件名的人”,挡不住”能枚举文件名的人”。有 (listfile) 等于把钥匙和锁一起交给玩家。这也是开源工具(warcrafty、StormLib)能无密钥读绝大多数 WoW 归档的原因。
12. 归档里的特殊文件
名字带 () 的三个文件不是游戏资产,而是归档元数据:
(listfile):文件名清单,枚举的唯一入口。通常不加密,可被剥离- **
(attributes)**:完整性属性表(CRC-32、时间戳、MD5),读档不需要它 - **
(signature)**:归档数字签名,3.3.5a 几乎不出现
“未知文件块未在树中显示”的答案就在这:当 list/tree 一个归档时,这三个文件被工具当作”内部实现细节”有意排除在用户视角之外。另外还有一种真·未知块——哈希表里有条目、但名字不在清单里(补丁增删未同步列表),哈希不可逆,只能显示占位名。
13. Python 实战与验证
自写脚本 mpq_parser.py 提供 info / list / extract / extractall / tree 五个命令,用 patch-zhCN-Y.MPQ 验证:
info:正确解析 v2 头、扇区大小、两张表容量与偏移list:枚举出全部文件路径,与 StormLib 一致extract:抽样 DBC / BLP / 文本与 StormLib 输出逐字节一致extractall:全量解出无异常,未出现”解压超长”或”扇区长度非法”tree:按目录层级正确归组,未知块提示准确(补丁包含(listfile)和(attributes),所以显示 2 个内部文件被排除)
tree 的实现要点:用嵌套字典表示目录树,分隔符 \ 和 / 都视为层级分隔,目录节点用 __dirs__/__files__ 两个键区分,避免和真实文件名撞名。
14. 跨语言移植清单
实现一份”能读 WoW 3.3.5 MPQ”的最小库,需要手写:build_storm_buffer、hash_string、encrypt/decrypt_block、next_key1、file_key、locate_header、parse_header、两张表读取、lookup、sector_offsets、read_sectors、sparse 解压。可借用标准库:zlib、bzip2、adler32。需要抄 StormLib 码表:PKWARE DCL。可跳过:Huffman/ADPCM(音频)、v3/v4、LZMA、写入/压紧/删除、补丁链。
32 位运算陷阱(语言相关):
- JavaScript:位运算强制 32 位有符号,需
>>> 0转 uint32 - Python:
int任意精度,每步要& 0xFFFFFFFF - Go:用
uint32类型,自动截断;注意<<优先级 - Rust:
u32::wrapping_*系列;<<在 debug 模式会 panic on overflow - Java:
int是 32 位有符号,常& 0xFFFFFFFFL提升到 long
15. 健壮性最佳实践(最容易踩的坑)
真实归档(尤其补丁包)经常处于”半损坏 / 被裁剪 / 非标”状态,读取器必须做这些校验:
- 读取长度先校验:表越界、
read实读字节数 < 期望长度,立即抛错 - 压缩上限当硬上界:bzip2 多块累计、sparse 假长度头、PKWARE 都可能骗分配,超限就抛
- 偏移表头:压缩文件
offsets[0]必须等于entryCount * 4;偏移必须单调递增 - **扇区校验数组”尽力而为”**:不满足条件降级跳过,别判损坏——StormLib 原话 “almost never present, often it’s empty”
- 加密方向:偏移表
key-1、扇区key+i,错了全读出 0xFF;hashString按 UTF-8 字节,非 ASCII 按码点会算错 - 性能:storm buffer 全局缓存一次;批量
struct.unpack/pack_into解密,别逐字节循环;rawLen == expected的扇区直接拷贝明文 - 容错:单文件失败不影响整体提取;未知压缩掩码先整体校验再解压
16. 参考实现
- warcrafty 2.1.2(calsranna/warcrafty,纯 Dart,MIT):体量最小的完整 MPQ 实现,模块清晰,本项目的图标提取就是基于它
- StormLib(Ladislav Zezula):业界事实标准,
SCompression.cpp是压缩派发权威 - wowdev.wiki/MPQ:社区维护的最完整格式文档,含 v3/v4
warcrafty 的 MPQ 子系统只导出两个符号:MpqArchive(读、写、压紧的单一入口)和 MpqException(9 个具体子类)。内部模块划分清晰:header.dart(定位/解析)、crypto.dart(加密原语)、hash_table.dart/block_table.dart(两张表)、sector_reader.dart/sector_writer.dart(读写主流程)、compression/*(各压缩编码)、listfile.dart(清单文件)。本项目只用到了读取侧四个 API:open / files / extract / close。