Hello World

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

0%

WOW 3.3.5a MPQ 归档格式与解析

最近在给 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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌───────────────────────────────────────────────┐
│ (可选) 自解压壳 / 前导数据 │ ← 不是 MPQ 的一部分
├───────────────────────────────────────────────┤
│ (可选) MPQ_USER_DATA 块(MPQ\x1B 开头) │ ← 头的前奏
├───────────────────────────────────────────────┤
│ ★ MPQ 归档头(MPQ\x1A,32 字节 v1 / 44 字节 v2) │ ← 一切的起点
├───────────────────────────────────────────────┤
│ 文件数据区:所有文件按"扇区"切片后紧密堆叠 │
│ 文件 A 的扇区偏移表 + 扇区 0 + 扇区 1 + … │ ← 真正的内容
│ 文件 B 的扇区偏移表 + 扇区 0 + … │
├───────────────────────────────────────────────┤
│ ★ 哈希表(每条 16 字节,固定密钥加密) │ ← 文件名 → 块索引
├───────────────────────────────────────────────┤
│ ★ 块表(每条 16 字节,固定密钥加密) │ ← 块索引 → 文件数据
├───────────────────────────────────────────────┤
│ (仅 v2) hi-block 表(每条 2 字节,不加密) │ ← 块偏移的高 16 位
└───────────────────────────────────────────────┘

核心是两张表:

  • 哈希表:把文件名(经哈希)映射到块表索引。开放寻址线性探测,容量恒为 2 的幂。
  • 块表:把块索引映射到「文件数据在归档里的字节范围 + 标志位」。

关键设计后果:MPQ 不存储文件名原文。所以”枚举归档内容”完全依赖一个内嵌的清单文件 (listfile)——它本身也是归档里的一个文件,内容是 \r\n 分隔的文件名列表。没有它就只能按已知名精确提取,无法列举。

3. 加密与哈希原语

所有运算都是 32 位无符号——用 64 位 int 实现时每一步都要 & 0xFFFFFFFF 截断。

3.1 加密表(storm buffer)

一张 0x500(=1280)个 32 位字的预生成表,所有加密/哈希都查它。算法是固定种子的线性同余递推:

1
2
3
4
5
6
7
8
9
10
11
12
13
def build_storm_buffer():
buf = [0] * 0x500
seed = 0x00100001
for index1 in range(0x100):
index2 = index1
for _ in range(5):
seed = (seed * 125 + 3) % 0x2AAAAB
high = (seed & 0xFFFF) << 16
seed = (seed * 125 + 3) % 0x2AAAAB
low = seed & 0xFFFF
buf[index2] = (high | low) & 0xFFFFFFFF
index2 += 0x100
return buf

易错点:填表是按”列”散布的(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
2
3
4
5
6
7
8
def hash_string(name: bytes, type_: int) -> int:
seed1 = 0x7FED7FED
seed2 = 0xEEEEEEEE
for b in name: # 注意:按 UTF-8 字节,不是按码点
ch = to_upper(b) # '/'→'\\'; 'a'-'z'→'A'-'Z'; 其余原样
seed1 = STORM[type_ + ch] ^ ((seed1 + seed2) & 0xFFFFFFFF)
seed2 = (ch + seed1 + seed2 + (seed2 << 5) + 3) & 0xFFFFFFFF
return seed1

to_upper 是一张定表——只把 / 换成 \、小写字母换大写,改其他字节(含 0x80 以上的非 ASCII)。文件名大小写不敏感、正反斜杠等价。

3.3 块加密(encrypt/decryptBlock)

固定密钥的 32 位分组对称加密,原地操作,处理单位是 32 位小端字,末尾不足 4 字节的部分原样不动

1
2
3
4
5
6
7
8
9
10
11
def decrypt_block(data: bytearray, key: int) -> bytearray:
key1 = key
key2 = 0xEEEEEEEE
for i in range(len(data) >> 2): # 只处理整字
key2 = (key2 + STORM[0x400 + (key1 & 0xFF)]) & 0xFFFFFFFF
plain = read_le32(data, i*4) ^ ((key1 + key2) & 0xFFFFFFFF)
write_le32(data, i*4, plain)
key1 = ((~key1 << 21) & 0xFFFFFFFF) + 0x11111111
key1 = ((key1 | (key1 >> 11)) & 0xFFFFFFFF) # next_key1
key2 = (plain + key2 + (key2 << 5) + 3) & 0xFFFFFFFF
return data

encryptBlock 对称:把 plain = read ^ (key1+key2) 改成 cipher = plain ^ (key1+key2),其余一致——两个方向都用明文推进 key2

3.4 文件密钥推导(fileKey)

加密文件用密钥由文件名末段推导,可选按位置/大小修正:

1
2
key = hashString(plainName(name), 0x300)
若 MPQ_FILE_KEY_V2:key = ((key + blockOffset) & 0xFFFFFFFF) ^ (fileSize & 0xFFFFFFFF)

plainName 取最后一个 \/ 之后的部分。blockOffset 是文件数据相对归档起始的偏移(即块表里的 offset 字段)。

3.5 两张表的全局密钥

整张哈希表 / 块表在归档里用固定密钥加密,与具体文件无关:

1
2
HASH_TABLE_KEY  = hashString('(hash table)',  0x300)   # == 0xC3AF3770
BLOCK_TABLE_KEY = hashString('(block table)', 0x300) # == 0xEC83B3A3

4. 头部定位与解析(操作 ①)

4.1 魔数

1
2
MPQ_SIGNATURE     = 0x1A51504D   # 'M','P','Q','\x1A'
MPQ_USER_DATA_SIG = 0x1B51504D # 'M','P','Q','\x1B' → 用户数据块,归档头跟在后面

4.2 头部定位算法

归档头不一定从文件开头起——前面可能有自解压壳或用户数据块。按 512 字节边界扫描:

1
2
3
4
5
6
7
8
9
10
11
12
def locate_header(data: bytes) -> int:
length = len(data)
for offset in range(0, length - 32, 512):
sig = read_le32(data, offset)
if sig == 0x1A51504D:
return offset
if sig == 0x1B51504D:
target = offset + read_le32(data, offset + 8) # 用户数据块内偏移 +8 是归档头位移
if target + 32 > length:
raise ValueError("用户数据块指向越界")
return target
raise ValueError("未找到 MPQ 归档头")

注意扫描步长是 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
2
3
4
if version >= 1 and len(header) >= 44:
hi_block_pos = read_u64(header, 32)
hash_table_pos |= read_u16(header, 40) << 32
block_table_pos |= read_u16(header, 42) << 32

4.5 三道校验

  • formatVersion ∈ {0,1}(>1 是 v3/v4,超出范围)
  • hashTableSize > 0 且是 2 的幂:(size & (size-1)) == 0
  • sectorSizeShift <= 23(扇区大小不超过 512 << 23 ≈ 4GB)

4.6 派生量

1
2
3
4
5
archiveOffset        # 头在文件中的绝对偏移
sectorSize # = 512 << sectorSizeShift
hashTableOffset # = archiveOffset + hashTablePosition
blockTableOffset # = archiveOffset + blockTablePosition
hiBlockTableOffset # = archiveOffset + hiBlockTablePosition(v2 且非 0)

所有”相对偏移”都要加上 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
2
FREE    = 0xFFFFFFFF   # 从未用过;查找遇到它即终止
DELETED = 0xFFFFFFFE # 曾用过、已删除;查找遇到它要继续探测

整张表落盘时整体加密一次,密钥 = HASH_TABLE_KEY。读取时把 hashTableSize × 16 字节整块读出,调一次 decryptBlock(bytes, HASH_TABLE_KEY),再按 16 字节切分。

5.2 哈希表查找(开放寻址线性探测)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def lookup(hash_table, name: str, locale=0):
start = hash_string(name.encode(), 0x000) & (len(hash_table) - 1)
name_a = hash_string(name.encode(), 0x100)
name_b = hash_string(name.encode(), 0x200)
neutral = any_ = None
slot = start
while True:
e = hash_table[slot]
if e.block_index == 0xFFFFFFFF: # FREE → 探测链到头
break
if e.block_index != 0xFFFFFFFE and e.name_a == name_a and e.name_b == name_b:
if e.locale == locale: return e.block_index
if e.locale == 0: neutral = neutral if neutral is not None else e.block_index
any_ = any_ if any_ is not None else e.block_index
slot = (slot + 1) & (len(hash_table) - 1)
if slot == start:
break
return neutral if neutral is not None else any_

命中判据:双哈希 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
2
3
4
5
6
7
8
9
10
IMPLODE        = 0x00000100   # PKWARE DCL;扇区内没有掩码字节
COMPRESS = 0x00000200 # 通用压缩;每个压缩扇区首字节是方法掩码
COMPRESS_MASK = 0x0000FF00 # 任一压缩方式
ENCRYPTED = 0x00010000
FIX_KEY = 0x00020000 # 又名 KEY_V2;密钥按 offset/size 修正
PATCH_FILE = 0x00100000 # 补丁文件,起始处有 TPatchInfo
SINGLE_UNIT = 0x01000000 # 整个文件作为一个块,没有扇区偏移表
DELETE_MARKER = 0x02000000 # 补丁删除标记
SECTOR_CRC = 0x04000000 # 每个扇区末尾附带 adler32
EXISTS = 0x80000000 # 条目有效

6. 列出归档内容(操作 ②)

MPQ 不存原文文件名——哈希表只存 nameA/nameB,不可逆。**枚举归档完全依赖内嵌清单文件 (listfile)**(名字带括号、全小写)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def list_files(archive) -> list[str]:
if not archive.contains("(listfile)"):
return [] # 无法枚举
raw = archive.extract("(listfile)") # 走操作 ③
text = raw.decode("utf-8", "replace")
seen, result = set(), []
for line in re.split(r"[\r\n;]+", text):
name = line.strip()
if not name or name in seen:
continue
seen.add(name)
if name in {"(listfile)", "(attributes)", "(signature)"}:
continue # 内部元数据文件
if archive.contains(name): # 过滤已删除条目
result.append(name)
return result

易踩坑

  • 清单可能引用已不存在的文件(条目被删但清单未更新)——用 contains(name) 过滤
  • StormLib 只按 \r\n 切且把空格当名字一部分;宽松解析(兼容 \n / ; / trim)对真实归档几乎无副作用
  • contains(name) 内部就是查哈希表 → 块表条目的 EXISTS

7. 提取单个文件(操作 ③)

最复杂的一步。完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def extract(archive, name: str) -> bytes:
idx = archive.hash_table.lookup(name)
if idx is None: raise FileNotFoundError(name)
block = archive.block_table[idx]
if not (block.flags & 0x80000000): raise FileNotFoundError(name)
if block.flags & 0x02000000: raise ValueError("文件是补丁删除标记")
if block.flags & 0x00100000: raise ValueError("不支持补丁文件")
if block.file_size == 0: return b""

data_offset = archive.archive_offset + block.offset
key = file_key(name, block.offset, block.file_size, block.flags & 0x00020000) \
if (block.flags & 0x00010000) else 0

if block.flags & 0x01000000: # SINGLE_UNIT
return read_single_unit(archive.file, block, data_offset, key)
else:
return read_sectors(archive.file, archive.header, block, data_offset, key)

7.1 单块文件(SINGLE_UNIT)

整个文件作为一个块,没有扇区偏移表

1
2
3
4
5
6
7
8
9
def read_single_unit(f, block, data_offset, key) -> bytes:
raw = f.read_at(data_offset, block.compressed_size)
if block.flags & 0x00010000:
decrypt_block(raw, key) # 整块用同一密钥,不掺扇区索引
if (block.flags & 0x0000FF00) and block.compressed_size < block.file_size:
plain = decompress_by_flags(raw, block.file_size, block.flags)
if len(plain) > block.file_size: raise ValueError("解压超长")
return plain
return raw[:block.file_size] # 未压缩:raw 可能因对齐更长,截断

7.2 多扇区文件(默认形态)

文件按 sectorSize(典型 4096)切成 N 个扇区,独立压缩/加密。数据开头是一张扇区偏移表(仅压缩时存在)。

7.2.1 取扇区偏移表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def sector_offsets(f, block, data_offset, key, sector_count, sector_size) -> list:
if not (block.flags & 0x0000FF00):
# 未压缩:偏移按扇区大小线性推算
return [min(i * sector_size, block.compressed_size) for i in range(sector_count + 1)]
entry_count = sector_count + (2 if (block.flags & 0x04000000) else 1)
b = f.read_at(data_offset, entry_count * 4)
if block.flags & 0x00010000:
decrypt_block(b, (key - 1) & 0xFFFFFFFF) # ★偏移表密钥 = 文件密钥 - 1
offsets = [read_le32(b, i*4) for i in range(entry_count)]
if offsets[0] != entry_count * 4: raise ValueError("偏移表头非法")
for i in range(sector_count):
if offsets[i+1] < offsets[i]: raise ValueError("扇区偏移非单调")
if offsets[i+1] - offsets[i] > sector_size: raise ValueError("扇区长度超扇区大小")
return offsets

关键常量:偏移表的加密密钥是 (fileKey - 1) & 0xFFFFFFFF——不是 fileKey 本身。这是 StormLib 的固定约定,搞错就读出一串废数据。

附加校验:偏移表第一项 offsets[0] 恒等于表自身长度 entryCount * 4

7.2.2 逐扇区读取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def read_sectors(f, header, block, data_offset, key) -> bytes:
sector_size = header.sector_size
sector_count = ((block.file_size - 1) // sector_size) + 1
offsets = sector_offsets(f, block, data_offset, key, sector_count, sector_size)

output = bytearray()
for i in range(sector_count):
remaining = block.file_size - len(output)
expected = min(remaining, sector_size)
raw_len = offsets[i+1] - offsets[i]
if raw_len <= 0: raise ValueError("扇区长度非法")
raw = f.read_at(data_offset + offsets[i], raw_len)
if block.flags & 0x00010000:
decrypt_block(raw, (key + i) & 0xFFFFFFFF) # ★每扇区密钥 = fileKey + 扇区索引
# ★核心判据:只有 rawLen < expected 时该扇区才是真压缩过
plain = decompress_by_flags(raw, expected, block.flags) if raw_len < expected else raw
if len(plain) < expected: raise ValueError("扇区解压后过短")
output += plain[:expected]
if len(output) != block.file_size: raise ValueError("解压总长不匹配")
return bytes(output)

两个不可忽视的细节

  1. 每扇区密钥(key + i) & 0xFFFFFFFF——扇区索引参与密钥。偏移表是 (key - 1),扇区是 (key + i)
  2. “是否真压缩”判据rawLen < expected。压缩侧若发现某扇区压缩后反而变大,会退回存原始字节(”压缩没有收益”)——读取侧必须用同一判据,否则会把明文当压缩流去解,失败。

7.3 decompressByFlags 派发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def decompress_by_flags(raw: bytes, expected: int, flags: int) -> bytes:
if flags & 0x00000100: # IMPLODE 优先,且没有掩码字节
return pkware_explode(raw, expected)
if flags & 0x00000200: # 通用压缩,首字节是掩码
return mpq_decompress(raw, expected)
raise ValueError("扇区短于解压长度但未声明压缩方式")

def mpq_decompress(raw: bytes, expected: int) -> bytes:
mask = raw[0]
if mask == 0: raise ValueError("压缩掩码为 0 但被标为压缩")
payload = raw[1:]
# 先整体校验未知掩码位,避免解到一半才发现
if mask & ~(0x01 | 0x02 | 0x08 | 0x10 | 0x20 | 0x40 | 0x80):
raise ValueError("未知压缩掩码: %#x" % mask)
cur = payload
if mask & 0x10: cur = bz2.decompress(cur) # bzip2
if mask & 0x08: cur = pkware_explode(cur, expected) # PKWARE
if mask & 0x02: cur = zlib.decompress(cur) # zlib
if mask & 0x01: raise ValueError("不支持 Huffman(音频)")
if mask & 0x80: raise ValueError("不支持 ADPCM 立体声(音频)")
if mask & 0x40: raise ValueError("不支持 ADPCM 单声道(音频)")
if mask & 0x20: cur = sparse_decompress(cur, expected) # sparse 在最后
return cur

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
2
3
4
5
6
7
8
解压顺序(按表序,循环检查每一位):
1. bzip2 (0x10)
2. PKWARE (0x08)
3. zlib (0x02)
4. Huffman (0x01)
5. ADPCM_S (0x80)
6. ADPCM_M (0x40)
7. sparse (0x20) ← sparse 在最后

为什么 sparse 在最后:sparse 是 LZ77 风格的零填充游程,输出可能给后续解压”喂”长度修正过的缓冲区。压缩侧的顺序正好相反(先 sparse 再 zlib),保证解压侧按 dcmp_table 还原回原文。

8.3 adler32

MPQ 的扇区校验用 adler-32,初值是 0(标准 RFC 2920 的初值是 1):

1
2
3
4
5
6
7
8
9
10
11
12
13
def adler32(data: bytes, seed: int = 0) -> int:
MOD = 65521
a, b = seed & 0xFFFF, (seed >> 16) & 0xFFFF
i = 0
while i < len(data):
n = min(5552, len(data) - i) # 每 5552 字节内 b 不会溢出 32 位
for k in range(i, i + n):
a += data[k]
b += a
a %= MOD
b %= MOD
i += n
return ((b << 16) | a) & 0xFFFFFFFF

别用标准库默认初值 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
2
3
4
5
6
7
8
9
def extract_all(archive, out_dir):
for name in list_files(archive):
try:
data = extract(archive, name)
path = os.path.join(out_dir, name.replace("\\", "/"))
os.makedirs(os.path.dirname(path), exist_ok=True)
open(path, "wb").write(data)
except Exception as e:
print(f"skip {name}: {e}") # 单文件失败不中断整档

内存考虑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_bufferhash_stringencrypt/decrypt_blocknext_key1file_keylocate_headerparse_header、两张表读取、lookupsector_offsetsread_sectorssparse 解压。可借用标准库:zlib、bzip2、adler32。需要抄 StormLib 码表:PKWARE DCL。可跳过:Huffman/ADPCM(音频)、v3/v4、LZMA、写入/压紧/删除、补丁链。

32 位运算陷阱(语言相关)

  • JavaScript:位运算强制 32 位有符号,需 >>> 0 转 uint32
  • Pythonint 任意精度,每步要 & 0xFFFFFFFF
  • Go:用 uint32 类型,自动截断;注意 << 优先级
  • Rustu32::wrapping_* 系列;<< 在 debug 模式会 panic on overflow
  • Javaint 是 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