简介:面向游戏开发者和C++编程学习者,这份压缩包提供暴风雪公司MPQ文件查看器及完整源码。MPQ是《魔兽争霸》《星际争霸》等暴雪游戏常用的压缩格式,普通文件管理器无法直接查看,该工具能解析并展示MPQ包内数据,开发者可借此理解文件读取、解压、二进制解析和内存管理等实现。压缩包共62个文件,约1.29MB,含14个头文件、7个C++源文件、3个静态库,以及chm帮助文档、html页面、bmp/jpg素材和dsp/dsw工程文件,结构完整。程序涉及mdx、blp等魔兽3模型与贴图资源,也给出三维数据读取和展示的参考。目前已有254人学习下载,适合研究暴雪游戏资源格式、学习C++项目实践或扩展自定义工具的人群。 我大概在2017年左右开始折腾《魔兽争霸3》的地图汉化,那时候最头疼的就是怎么把地图里那一堆模型、贴图和文本文件从 .mpq 后缀的大文件里拆出来。网上下到的MPQ文件查看器要么不开源、要么在64位系统上各种报错,逼得我最后只能去找一套带源码的方案自己改。也就是那段时间,我养成了一个习惯:拿到这种工具第一件事不是直接双击运行,而是先把源码里最核心的解析逻辑读一遍。“暴风雪公司MPQ文件查看器(带源码).zip”这个包,正好就是这种可以用来学习、也可以拿来改成自己顺手的工具的起点。这篇文章我会从MPQ格式本身讲起,逐步拆解一个查看器程序的核心设计,中间穿插一些我实际踩过的坑,希望对想搞懂这类打包格式的朋友有帮助。
1. 为什么一个十年前的格式还值得动手写查看器
1.1 暴雪游戏资产与MPQ的王牌价值
暴雪娱乐从《暗黑破坏神》时代开始用MPQ(Mo’PaQ)作为游戏资源的容器格式,之后的《星际争霸》《魔兽争霸3》《魔兽世界》都在用。它的核心思路其实很朴素:游戏运行时要读成千上万个文件,如果全部散落在磁盘上,碎片化严重、读写效率低,打包成单个大文件之后,操作系统加载的压力会小很多。
MPQ的设计在当时相当超前,它支持高强度加密、文件压缩、按hash快速查找,甚至在《魔兽世界》里还支持“后续补丁直接覆盖旧文件而不需要重新打包整个文件”的能力。这也是为什么暴雪游戏这么多年下来,MOD作者、地图汉化组、模型提取爱好者都绕不开它。一个游戏资源打包格式能活二十多年,说明它的底层设计是经得起推敲的。
如果你现在手里握着一个带源码的MPQ查看器,你其实拥有的不是一个小工具,而是一套理解二进制容器格式的完整范本。懂了MPQ再看其他游戏的资源打包格式,比如Unity的Bundle、虚幻的PAK,思路会顺畅很多。
1.2 带源码的意义:不是工具,是教材
网上能下载到的MPQ工具不少,比如StormLib、MPQEditor,但真正“带源码”且结构清晰的并不多。有源码的价值在于几件事:
- 你可以改功能,比如把“只能查看”改成“支持批量导出”,甚至加一个命令行接口。
- 你可以移植到其他平台,比如用C/C++写的解析部分直接编译到Android或Linux。
- 你可以在读源码的过程中搞懂每一个字节的含义,这种理解是看文档替代不了的。
我个人的建议是,拿到这类源码后不要急着编译,先打开核心文件,找到三个结构体定义:MPQ头、哈希表项、块表项。只要这三个结构体看明白了,MPQ格式你就已经懂了一半。
2. MPQ格式的四个核心数据结构
2.1 文件头:从0x1A51504D开始
任何MPQ文件的前4个字节一定是0x1A 0x51 0x50 0x4D,从内容上看反着读就是“MPQ\x1A”。这个魔数检查通过后,才能继续读后面的字段。
一个标准的MPQ文件头在《魔兽争霸3》那一代通常是32字节:
struct MPQHeader { uint32_t magic; // 0x1A51504D uint32_t headerSize; // 通常为 32,扩容版本可能是 48 uint32_t archiveSize; // 整个MPQ包的实际大小 uint16_t formatVersion; // 0 表示经典格式,1 表示含扩展头 uint16_t blockSize; // 扇区大小偏移量(以512字节为单位) uint32_t hashTableOffset; // 哈希表在文件中的偏移 uint32_t blockTableOffset; // 块表在文件中的偏移 uint32_t hashTableEntries; // 哈希表的条目数量 uint32_t blockTableEntries;// 块表的条目数量 };这里的blockSize不是直接的字节数,而是表示为512 << blockSize。比如值为3时,真正扇区大小是4096字节。压缩时数据会被切成这么多大小的块,分别压缩。我第一版代码就忽略了这个移位,导致大文件解压出来全是乱码,检查了半天才想起来去看官方定义。
2.2 哈希表:用查找代替遍历
哈希表是整个MPQ格式的精髓。暴雪设计它的原因很直接:如果文件名列表很大,每次查找都读一遍文件目录,性能开销不可接受。所以MPQ使用了一个固定大小的哈希表,通过三个哈希值来定位文件。
哈希表的每一项是16字节:
struct HashTableEntry { uint32_t name1; // 文件名的第一个哈希值 uint32_t name2; // 文件名的第二个哈希值 uint32_t locale; // 语言区域(zhCN、enUS等) uint32_t platform; // 平台标记 uint32_t fileBlockIndex; // 对应块表的索引 };查找过程的逻辑大致是这样的:
- 对文件名做一次哈希,得到哈希索引。
- 跳转到哈希表的对应位置,检查
name1和name2是否匹配。 - 如果匹配,再检查
fileBlockIndex,如果值等于0xFFFFFFFE,说明该文件已被删除;如果是正常索引,就跳转到块表对应项。 - 如果不匹配,线性探测下一项,直到遇到空项(所有字段为
0xFFFFFFFF)才算彻底未找到。
为什么哈希索引计算要用hash0 & (entries - 1)而不是取模?因为MPQ头的哈希表大小固定为2的幂,位运算比取模快得多。这个细节看似微小,但对大文件频繁查找来说,性能差异很明显。
2.3 块表:真正记录文件位置的数据结构
哈希表解决的是“文件名到条目”的映射,块表则记录“文件数据如何在MPQ包中存放”。每一项也是16字节:
struct BlockTableEntry { uint32_t filePosition; // 文件数据在MPQ中的绝对偏移 uint32_t compressedSize; // 压缩后大小 uint32_t uncompressedSize; // 解压后大小 uint32_t flags; // 文件属性标志 };flags是理解MPQ的关键,几个常用位如下:
| 标志位 | 值 | 含义 |
|---|---|---|
| MPQ_FILE_IMPLODE | 0x00000100 | 使用PKWARE DCL压缩 |
| MPQ_FILE_COMPRESS | 0x00000200 | 使用多算法压缩(ZLib/BZip2等) |
| MPQ_FILE_ENCRYPTED | 0x00010000 | 文件内容已加密 |
| MPQ_FILE_FIX_KEY | 0x00020000 | 解密密钥依赖文件偏移 |
| MPQ_FILE_SINGLE_UNIT | 0x01000000 | 整个文件作为一个压缩单元 |
| MPQ_FILE_DELETE_MARKER | 0x02000000 | 文件已被删除 |
| MPQ_FILE_EXISTS | 0x80000000 | 文件真实存在 |
读取文件时,先根据filePosition把压缩数据读到内存,然后判断压缩方式。如果flags带MPQ_FILE_COMPRESS,数据块的第一个字节是压缩算法组合标识,后面才是真正的压缩流。如果带MPQ_FILE_ENCRYPTED,必须在解压前先解密。
2.4 加密表与密钥:暴雪的反逆向思路
MPQ的加密算法虽然不算特别强,但思路很有意思。它维护一个0x500个DWORD的表,叫做加密表或者Storm表,所有文件名哈希和文件内容加解密都依赖这张表。
加密表的初始化是一个固定的伪随机过程,典型代码如下:
void InitCryptoTable(uint32_t cryptoTable[0x500]) { uint32_t seed = 0x00100001; for (int index1 = 0; index1 < 0x100; index1++) { for (int index2 = index1, index3 = 0; index3 < 5; index3++) { seed = (seed * 125 + 3) % 0x2AAAAB; uint32_t temp1 = (seed & 0xFFFF) << 16; seed = (seed * 125 + 3) % 0x2AAB; uint32_t temp2 = seed & 0xFFFF; cryptoTable[index2] = temp1 | temp2; index2 += 0x100; } } }文件内容解密的时候,密钥由文件名哈希和可能的偏移量共同决定。解密过程会不断更新两个种子值,让每个8字节或者4字节的解密结果都和前面的结果关联,破解起来比较费劲。
对查看器开发来说,你不需要重新发明这套算法,但一定要保证加密表初始化正确。如果加密表生成错误,最明显的症状就是文件名全是乱码,或者listfile文件解出来是一堆无意义数据。
3. 查看器程序的设计与实现
3.1 技术选型:解析纯二进制,C/C++依然最直接
写MPQ查看器,编程语言的自由度其实很高。C#、Java、Python都能写,但如果你追求性能和二进制操作的直觉,C/C++是最合适的。
C++里可以直接用fstream或mmap读取整个文件,然后通过结构体指针直接映射到内存,效率非常高。用Python的话虽然开发速度快,但逐字节操作和大量解压调用会明显慢,尤其是处理像《魔兽世界》动辄几十GB的MPQ文件时,体验差距很大。
我看到过不少带源码的MPQ查看器都是基于C++写的,配合Qt或者Win32做界面。如果你只是学习原理,可以不关心界面,做一个命令行工具,专注实现三个核心接口:
class MPQArchive { public: bool Open(const char* path); bool GetFileList(std::vector<std::string>& outList); bool ExtractFile(const std::string& name, const char* outputPath); };3.2 解析器类的最小骨架
无论界面长什么样,解析器的骨架基本是一致的:
bool MPQArchive::Open(const char* path) { std::ifstream file(path, std::ios::binary); file.read(reinterpret_cast<char*>(&header_), sizeof(header_)); // 检查魔数 if (header_.magic != 0x1A51504D) { return false; } // 读取哈希表 hashTable_.resize(header_.hashTableEntries); file.seekg(header_.hashTableOffset); file.read(reinterpret_cast<char*>(hashTable_.data()), header_.hashTableEntries * sizeof(HashTableEntry)); // 读取块表 blockTable_.resize(header_.blockTableEntries); file.seekg(header_.blockTableOffset); file.read(reinterpret_cast<char*>(blockTable_.data()), header_.blockTableEntries * sizeof(BlockTableEntry)); return true; }打开文件这一步其实没什么难度,真正的坑在后面的查找和解压。
3.3 文件列表的获取策略
MPQ里的文件名默认不会直接暴露出去,而是存储在一个名为(listfile)的内部文件里,这个文件本身可能被加密。查看器通常有两种方式获得文件名列表:
- 优先尝试从
(listfile)文件读取。读取流程和普通文件一样,先哈希查找,再读取内容,如果加密则解密,最后按文本行解析。 - 如果
(listfile)不存在或者为空,就只能暴力扫描哈希表。把哈希表里所有有效项遍历一遍,但只能得到哈希值和块表索引,拿不到明文文件名,这时候需要配合外部词典或者人工猜测。
我在做地图汉化时经常遇到的情况是:某些第三方地图的作者故意删掉了(listfile),这时候查看器列表里只有一片“文件1”“文件2”这样的占位名。处理办法是把地图文件和原版游戏目录的同名文件比对,通过文件大小和压缩方式推断内容。
3.4 解压模块需要处理哪些算法
MPQ历史上用过的压缩算法不少,查看器不可能只依赖一种。常见的有:
| 压缩算法 | 常见标识 | 实现建议 |
|---|---|---|
| ZLib | 0x02 | 各语言都有现成库,直接用 |
| BZip2 | 0x08 | 也建议直接用库 |
| Huffman | 0x01 | 暴雪自定义的Huffman变体,需要自己实现 |
| PKWARE DCL | 0x04 | 老版本暗黑/星际常用,库较少 |
| LZMA | 0x10 | 《魔兽世界》用得多,可用liblzma |
Warcraft III版本的地图通常用的是ZLib和BZip2,配合Huffman。写查看器的时候,我建议先支持ZLib,因为这个覆盖了大多数场景,然后再按需求补充其他算法。
解压时的关键点是:不要对整份数据直接解压,而是先看压缩块的第一个字节,根据压缩标识决定解压策略。比如标识是0x02,直接对剩余数据做ZLib inflate;如果标识是0x03,说明先用Huffman解压,再做ZLib解压,顺序不能反。
4. 实机演示:从War3地图中提取模型与贴图
4.1 准备一个真实存档
理论说再多,不如实际跑一遍。我以一份普通的魔兽争霸3自定义地图为例,文件名叫test.w3x。实际上.w3x本身就是一个MPQ格式的容器,只是后缀名不同,所以直接用查看器打开即可。
打开后先看一下文件列表,你会看到类似这样的内容:
war3map.j war3map.wtg war3map.wts war3map.doo war3map.shd war3mapMap.blp这些是地图的逻辑脚本、触发数据、文本字符串和地图预览图。对于汉化工作来说,最需要提取的是war3map.wts,里面保存了所有界面文本。提取出来之后用文本编辑器打开,可以直接修改单位名称、技能说明,然后找工具回写到地图里。
4.2 提取指定文件并验证
提取文件的流程分几步走:
- 用
HashString计算目标文件名在哈希表里的索引。 - 找到匹配的哈希表项,拿到
fileBlockIndex。 - 根据
fileBlockIndex查块表,拿到偏移、压缩大小和标记位。 - 如果文件加密,用文件名加上块偏移计算密钥,先解密。
- 根据压缩标记决定是否解压,写成输出文件。
以war3mapMap.blp为例,这是一个贴图文件,提取出来后可以直接用图片查看器打开。如果打开报错,多半是解压或解密步骤出了问题,而不是提取本身的问题。判断方法是用十六进制编辑器看文件前几个字节,BLP贴图的魔数通常是BLP1或BLP2,如果文件头不对,说明数据在处理过程中已经损坏。
4.3 实操过程中的小意外
我遇到过一个很典型的问题:列表里有文件名,文件大小也对,但提取出来永远是0字节。后来查了半天发现,那份地图的块表项flags里带有MPQ_FILE_DELETE_MARKER,文件在逻辑上已经被删除了,只是在哈希表里还残留着条目。查看器如果没判断这个标志位,就会试图提取一块压根不存在的数据。
所以说,写提取逻辑时,flags的判断一定要完整,尤其是MPQ_FILE_EXISTS、MPQ_FILE_DELETE_MARKER、MPQ_FILE_ENCRYPTED这三个,少一个都可能让你调试到怀疑人生。
5. 开发与调试中的五个关键避坑方向
5.1 用二进制编辑器先看再写代码
我强烈建议在动手写任何解析代码之前,先用HxD或者010 Editor打开一个真实的MPQ文件,对着文件头、哈希表、块表逐段查看。只有亲眼看到那些字节的排列,你才能真正理解为什么hashTableOffset是从文件头开始的偏移量,为什么哈希表项在内存里是连续排列的。
很多新手一上来就照着别人的源码抄,抄完发现读出来的数据不对,其实是因为没理解某些字段和偏移的对应关系。纸上得来终觉浅,二进制编辑器就是MPQ入门最好的老师。
5.2 加密表初始化必须逐字校验
加密表初始化这件事,看起来只是一个循环和几个魔数,但里面任何一个常数写错,都会导致后面所有哈希计算和解密全部错误。更难受的是,错误不是立刻显现的,而是隐藏在某些文件里,可能5分钟后才暴露。
我建议把加密表生成代码单独抽出来,写一个单元测试,把生成结果的前几个值和公开源码对比。很多带源码的MPQ项目里都有现成的参考实现,直接对照一目了然。
5.3 压缩标志位不能只判断一位
MPQ的flags是一个32位整数,多种属性可以同时存在:一个文件既可以是加密的,也可以是压缩的,还可以是分块存储的。有些人写代码时只判断某一个位,其他位直接忽略,这会导致在某种文件上偶发解压失败。
正确的做法是每处理一个文件都完整检查所有相关位,形成清晰的流程分支。用现代调试器做条件断点,也会省很多时间。
5.4 ArchiveSize与真实文件大小的差异
MPQ头里的archiveSize理论上表示整个MPQ包的大小,但实际文件可能因为补丁等原因末尾多出几百字节数据。我第一次用archiveSize作为文件读取总长度时,程序报错说“读取越界”,后来改成直接用std::ifstream的seekg到文件末尾获取真实大小,才彻底解决。
所以每次打开文件后,建议读取真实文件大小,并和archiveSize做一次对比。如果差异较大,至少打印一条警告日志,方便后续排查。
5.5 版本兼容:别忽略扩展头
《魔兽争霸3》和《魔兽世界》经典版本用的是32字节文件头,但后来出现了带扩展头的MPQ,headerSize可能大于32,甚至包含64位的大文件偏移。如果你的查看器只按32字节结构体解析,在高版本文件上很可能会定位到错误的位置。
一个比较稳妥的做法是:读头信息时不要直接用固定结构体覆盖,而是先读取前32字节,然后根据formatVersion和headerSize再决定是否继续读扩展部分。
最后再分享一个小技巧
调试MPQ解析最有效的办法不是打断点,而是做一个“对比模式”:用已知好用的工具(比如MPQEditor)导出某个文件的原始数据和属性,再让你的程序输出同样的信息,逐字段对比。差异出现在哪里,问题就在哪里。
这套带源码的MPQ文件查看器,我拿来做的最值的一件事,就是把它改造成了一个支持批量导出和命令行调用的工具,配合写地图MOD的流程,效率提升了不止一倍。对于想深入理解游戏资源格式的人来说,把MPQ吃透,等于拿到了一把通用钥匙,之后再碰其他容器格式都会觉得亲切很多。
本文还有配套的精品资源,点击获取