ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

手写MPQ文件查看器:从哈希表到StormLib集成实战

手写MPQ文件查看器:从哈希表到StormLib集成实战 简介一份面向游戏编程爱好者和资源分析初学者的MPQ文件查看器源码包用于解析暴雪娱乐《魔兽争霸》《星际争霸》《暗黑破坏神》等经典游戏采用的MPQ打包格式可帮助用户直接查看和提取游戏内部资源。源码以C编写除程序主入口和通用工具函数外还实现了MDX模型加载、BLP纹理解码、3D数学运算等模块并配有资源对话框、工具栏图标和关于界面能让读者串联起MPQ解包、内部资源提取与模型渲染的完整链路。压缩包共61个文件包含14个头文件、7个C源文件另有5张图片、4个HTML帮助文档、3个静态库和13个图标资源整体仅1.23MB紧凑易读工程附带了VC6工作区与项目文件可直接编译调试。目前已有589人学习下载适合想深入探究暴雪游戏资源结构、提升游戏文件处理能力的中级开发者作为实践参考也是一份难得的游戏资源逆向学习素材。 上周有个玩《暗黑破坏神2》重制版的朋友找我说自己想把旧版客户端里的角色语音和地图音乐扒出来做怀旧合集结果在社区里翻了一圈要么是十年前的单文件命令行工具要么只支持特定老版本还有几个挂着免费名头实际要用积分兑换的“商业软件”。他最后丢给我一句干脆你自己写一个MPQ文件查看器得了。我确实写了还顺手把源码整理了出来就是标题里这个项目。先解释一下命名Blizzard Entertainment直译过来就是暴风雪公司国内玩家习惯叫暴雪反正说的是同一家。这家公司从《暗黑破坏神》时代起就把游戏里几乎所有资源——音频、贴图、模型、地图、文本、UI配置——全部打包进*.MPQ这种私有归档格式。MPQ文件查看器要解决的就是把装在归档里的资源一个一个列出来、解出来、预览出来并且这次要从头到尾把源码摊开讲明白让拿到代码的人不仅能用还能改、能学。1. 为什么要自己写这个MPQ查看器市面上的工具不是没有而是每一种都让人觉得“差口气”。我梳理了一下常见的几类替代品它们的短板非常典型纯命令行老工具比如早年流传的MPQExtractor、WinMPQ只支持MPQ v1/v2老格式遇到《星际争霸2》以后的高版本MPQ直接打不开而且没有源码或源码年久失修。游戏Mod社区专用工具像WAR3的War3ModelEditor内部也带资源查看功能但它绑定单一游戏换个游戏引擎场景就完全没法用。商业或共享软件部分工具功能全但要么闭源要么文件提取速度、界面交互都很旧还时不时弹出收费提示。另外老工具普遍忽视一个问题现代暴雪游戏的资源不止在一个MPQ文件里。你打开安装目录一看主游戏是game-data-xx.mpq后面跟着一堆patch-base-.mpq、patch-hotdata-.mpq它们不是独立的归档而是按顺序逐层覆盖的逻辑整体。很多工具只让你打开其中一个文件导致你提取出来的资源版本不对、内容不全。这个问题不自己写一个工具很难从根本上解决。所以我写这个MPQ文件查看器时目标非常明确要有完整的源码、能直接编译使用、支持多版本MPQ从暗黑2老格式到星际2新格式、能自动合并补丁包归档、带一个够用的图形界面。最终选定的方案是C StormLib Qt下文会逐个说清楚为什么是这个组合。2. MPQ文件的内部世界哈希表、区块表和那套加密要写MPQ查看器首先得知道MPQ里到底装的是什么。这里不扯太深的理论但几个核心概念绕不开否则你连API参数都看不懂。MPQ全称MoPaQ命名来自暴雪联合创始人Mike OBrien。它诞生于1996年当时硬盘慢、内存小所以这个格式特意设计成“用时间换空间”——尽量把索引数据压到最小同时查找文件要快。2.1 文件头一眼认出MPQ任何MPQ文件开头32字节是固定结构第一个字段是魔数typedef struct _TMPQHeader { DWORD dwID; // MPQ\x1A即0x1A51504D DWORD dwHeaderSize; // 头部大小一般是32 DWORD dwArchiveSize; // 整个MPQ文件大小 USHORT wFormatVersion; // 0老版本、1魔兽争霸3、2星际2等 USHORT wSectorSizeShift; // 扇区大小左移位数 DWORD dwHashTablePos; // 哈希表偏移 DWORD dwBlockTablePos; // 区块表偏移 DWORD dwHashTableSize; // 哈希表条目数 DWORD dwBlockTableSize; // 区块表条目数 } TMPQHeader;版本不同头部后面还会追加字段比如64位偏移、大文件支持等。但你只要读到wFormatVersion就知道该按哪个版本的结构去解析了。2.2 哈希表没有目录结构的目录MPQ里没有传统意义上的“文件名目录”文件定位靠的是哈希表。每一条哈希表项记录了三组信息typedef struct _TMPQHash { DWORD dwName1; // 文件名哈希的高位 DWORD dwName2; // 文件名哈希的低位两者组合校验文件名 USHORT wLocale; // 语言/区域信息比如enUS、zhCN USHORT wPlatform; // 平台信息 DWORD dwBlockIndex; // 指向区块表的下标 } TMPQHash;查找文件时它用文件名计算三个哈希值前两个用来快速匹配哈希表项第三个用来生成解密密钥。因为哈希表本来就按哈希值散列存放所以查询一个文件不需要扫描全表平均复杂度接近O(1)。这在90年代的设计里非常超前。拿图书馆做类比MPQ的哈希表像是一个卡片柜每张卡片上不写书名而写书名的编码编号你拿着编号去找抽屉而不是从第一本书翻到最后一本。2.3 区块表文件真正存放的位置哈希表解决了“文件是否存在、叫什么”的问题区块表则解决“文件数据在哪、多大、是否压缩加密”的问题typedef struct _TMPQBlock { DWORD dwFilePos; // 文件数据在MPQ中的偏移 DWORD dwCSize; // 压缩加密后的大小 DWORD dwFSize; // 解压前的原始大小 DWORD dwFlags; // 标志位标记是否加密/压缩/单文件 } TMPQBlock;dwFlags稍微记住几个关键的就行MPQ_FILE_IMPLODE (0x00000100)旧式压缩算法MPQ_FILE_COMPRESS (0x00000200)复合压缩deflate、bzip2、lzma组合MPQ_FILE_ENCRYPTED (0x00010000)文件数据加密MPQ_FILE_FIX_KEY (0x00020000)加密密钥需要修正MPQ_FILE_SINGLE_UNIT (0x01000000)整个文件作为一个扇区处理文件数据按扇区通常4096字节分割每个扇区可独立压缩这意味解压时不需要把整个文件塞进内存按需读扇区即可查看器做“只读预览”时非常友好。2.4 加密逻辑为什么有的文件提出来是乱码MPQ的加密算法本身并不复杂核心是“以文件名为基础生成密钥”。解密时先根据文件路径算出key hash(name, 2) - 1然后对每个32位块做异或解密。遇到带FIX_KEY标志的文件还要基于文件偏移补一次修正计算。这也是为什么很多简单提取器会解出乱码——它们根本没有正确处理标志位。自己写查看器时这块千万别自己实现直接交给StormLib处理就好但原理要懂不然遇到“文件解出来内容不对”时你会完全不知道去哪排查。3. 技术选型为什么是C、StormLib和Qt项目要“带源码”而不是“只给个exe”意味着别人拿到手以后得能看懂、能编译、能针对自己的需求改。选型的第一原则就是技术栈要主流、依赖要少、社区要活跃。3.1 格式解析自研 vs StormLib有人会觉得既然写查看器干脆从零解析MPQ显得技术含量高。我的建议是如果目的不是学习格式本身别这么做。MPQ格式的坑比想象中多不同游戏版本之间字段还有微调。你自己解析到暗黑2的v0格式没问题碰到星际2的v2格式加挂patch-chain可能要折腾几个星期。StormLib是目前社区事实标准的MPQ读写库由Storm开发社区维护支持从初版MPQ到新版MPQ以及部分WAR3地图格式。它最大的优点是API稳定、覆盖游戏多、读取路径久经考验。你只负责调用它拿数据格式细节由库处理。我见过不少项目想“轻量自研”最后荒废掉的案例基本都是被格式细节拖死的。做工具型项目先站在巨人的肩膀上把精力留给UI和功能逻辑才是正途。3.2 语言C的不可替代性MPQ查看器这种工具主流选择是C或C#。我最终选了C理由有三个StormLib本身就是C库直接C集成零成本不用搞P/Invoke或跨语言绑定。解包是典型IO密集CPU密集混合场景C对文件和内存的控制粒度更细可以精细控制大文件解压时的内存占用。跨平台需求很实际——暴雪游戏玩家Windows、macOS都有QtC天然双端支持。Python当然也能写开发速度快但打包成带GUI的独立工具体积会到几百MB性能上遇到超大MPQ也更容易卡顿不适合做“下载即可运行的资源浏览器”。3.3 GUIQt Widgets足够GUI我选了Qt Widgets而不是QML或Electron。原因是这个工具的核心界面就是“一个树形/表格文件列表 一个预览区域”Widgets的QTreeView、QTableView、QTextEdit组合起来又稳又轻。QML做复杂动效更合适但这里用不上Electron要套一个Chromium重量级明显过度。Qt的模型/视图架构还有个好处文件列表可以用QAbstractItemModel做懒加载模型面对几十万条记录的MPQ也能滚动流畅不会一次性卡死界面。4. 核心功能是怎样一行行写出来的理论说完了直接上代码。实际上这个查看器的核心操作就四个打开归档、遍历文件、解压提取、预览内容。下面按流程拆开讲。4.1 打开归档含补丁合并#include StormLib.h HANDLE hMpq nullptr; bool openMpq(const QString basePath, const QStringList patchPaths) { // 1. 打开基础MPQ文件 if (!SFileOpenArchive( basePath.toLocal8Bit().constData(), 0, MPQ_OPEN_READ_ONLY, hMpq)) { return false; } // 2. 如果有补丁包按顺序挂载 if (!patchPaths.isEmpty()) { for (const QString patchPath : patchPaths) { if (!SFileOpenPatchArchive( hMpq, patchPath.toLocal8Bit().constData(), nullptr, 0)) { // 补丁包不是必需时可以忽略失败并继续 qWarning() 无法打开补丁包: patchPath; } } } return true; }SFileOpenArchive的第二个参数是优先级数字0表示最高优先SFileOpenPatchArchive挂载补丁后StormLib会自动处理“同一文件在基础包和补丁包中取哪个版本”的逻辑。这一步做好跨版本浏览资源基本就没坑了。4.2 遍历文件列表StormLib提供了一组Find接口遍历方式非常直接void buildFileList(HANDLE hMpq, QStandardItemModel* model) { SFILE_FIND_DATA findData; HANDLE hFind SFileFindFirstFile(hMpq, *, findData, nullptr); if (hFind ! nullptr) { do { QString fileName QString::fromUtf8(findData.cFileName); QStandardItem* item new QStandardItem(fileName); item-setData(qint64(findData.dwFileSize), Qt::UserRole 1); model-appendRow(item); } while (SFileFindNextFile(hFind, findData)); SFileFindClose(hFind); } }这里有个实际经验不要一次性把全部文件塞进QTableWidget几万条会卡。我推荐用QAbstractTableModel做数据源界面只加载当前可见行滚动性能会有数量级提升。4.3 解压提取文件解压单个文件有两种方式一把梭SFileExtractFile或手动打开句柄分块读取。bool extractFile(HANDLE hMpq, const QString archivePath, const QString targetPath) { // 方式一StormLib内置提取简单但不支持进度 if (SFileExtractFile(hMpq, archivePath.toLocal8Bit().constData(), targetPath.toLocal8Bit().constData(), 0, nullptr)) { return true; } // 方式二分块读写大文件推荐可加进度条 HANDLE hFile nullptr; if (!SFileOpenFileEx(hMpq, archivePath.toLocal8Bit().constData(), SFILE_OPEN_BY_NAME, hFile)) { return false; } DWORD fileSize SFileGetFileSize(hFile, nullptr); QFile outFile(targetPath); if (!outFile.open(QIODevice::WriteOnly)) { SFileCloseFile(hFile); return false; } const DWORD kChunkSize 1 20; // 1MB std::vectorchar buffer(kChunkSize); DWORD totalRead 0; while (totalRead fileSize) { DWORD toRead qMinDWORD(kChunkSize, fileSize - totalRead); DWORD read 0; if (!SFileReadFile(hFile, buffer.data(), toRead, read, nullptr)) { SFileCloseFile(hFile); return false; } outFile.write(buffer.data(), read); totalRead read; } SFileCloseFile(hFile); return true; }大文件一定要用分块方式。例如暗黑2的过场视频BINK文件解压后动辄几百兆一次性读进内存轻则卡顿重则直接崩溃。我的做法是默认小文件走第一种超过50MB自动切换第二种并在界面显示进度条。4.4 预览场景查看器不能光能解压用户最直接的需求是“能不能先看再决定要不要提”。我实现的预览逻辑分三类文本类扩展名为txt/xml/lua/json/ini的直接读文本内容显示到QTextEdit注意用QTextCodec尝试多编码避免中文乱码。图片类BLP是魔兽的贴图格式DDS是主流纹理格式查看器里直接解析比较麻烦我建议先提取到临时目录再调用QImage有插件的话直接读否则提示用户用专业工具打开。音频类很多游戏音频是OGG/WAV封装的提供“提取后调用系统默认播放器”即可没必要在界面里塞一个播放器。预览这块是最容易膨胀需求的地方先做最核心的后续再拓展是正确姿势。5. 源码怎么组织才能让人拿起来就能编译源码这个东西光有代码没用还得让人能跑起来。我按工程化标准来组织仓库目录结构如下mpq-viewer/ ├── CMakeLists.txt ├── README.md ├── src/ │ ├── main.cpp │ ├── MainWindow.cpp │ ├── MainWindow.h │ ├── MpqArchive.cpp │ ├── MpqArchive.h │ ├── FileListModel.cpp │ ├── FileListModel.h │ ├── PreviewPane.cpp │ └── PreviewPane.h ├── third_party/ │ └── StormLib/ └── resources/ └── app.icns / app.ico5.1 CmakeLists的核心要点cmake_minimum_required(VERSION 3.16) project(MpqViewer LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 REQUIRED COMPONENTS Widgets) # StormLib 以子目录方式引入 add_subdirectory(third_party/StormLib) add_executable(mpq_viewer src/main.cpp src/MainWindow.cpp src/MainWindow.h src/MqArchive.cpp src/MqArchive.h src/FileListModel.cpp src/FileListModel.h src/PreviewPane.cpp src/PreviewPane.h ) target_link_libraries(mpq_viewer PRIVATE Qt6::Widgets StormLib )关键点StormLib用add_subdirectory引入而不是让用户手动装了库再去链接。CMake可以自动找到它的头文件和二进制体验好很多。如果考虑远程构建也可以用FetchContent从GitHub拉取但国内网络环境下我推荐直接把库目录放进仓库保证“克隆下来就能编”。5.2 依赖管理的两种做法我在README里给了两种方案方便不同环境的人推荐直接在third_party目录下放StormLib源码简单粗暴任何人clone后用CMake一键构建。备选用vcpkg安装StormLibCMake里通过find_package(StormLib)引入。适合已经有vcpkg环境的开发者。有一点务必跟读者说清楚Windows下MSVC编译器要用Release x64配置去构建官方下载的预编译Qt一般也是Release构建Debug/Release混用会导致运行时库冲突。6. 实测中的坑和优化中文路径、多语言文件、几十万文件的性能代码写出来是第一步真正烦人的永远是实测阶段遇到的边角问题。下面这几个坑我几乎每一个都踩过列出来供大家绕行。6.1 中文字符文件名与路径MPQ内部文件名是按UTF-8存放的但StormLib的老版本A接口在某些非英文系统下转码行为很迷。我一开始用toLocal8Bit()传给SFileOpenFileEx在中文Windows上偶尔会打不开文件。后来测试发现最稳妥的方案是优先使用宽字符接口Windows下API名带W的那些把QString直接转成const wchar_t*传入。Cross-platform的通用做法是编译时判断#ifdef _WIN32 HANDLE hFile nullptr; SFileOpenFileEx(hMpq, archivePath.toStdWString().c_str(), SFILE_OPEN_BY_NAME, hFile); #else HANDLE hFile nullptr; SFileOpenFileEx(hMpq, archivePath.toUtf8().constData(), SFILE_OPEN_BY_NAME, hFile); #endif宽字符在Windows上是原生支持的能最大程度避免区域设置导致的乱码。6.2 多语言文件的重名问题MPQ支持同一路径下不同locale的文件比如Data\local\string.zhCN和Data\local\string.enUS。用SFileFindFirstFile遍历时会发现它们名字一样只是内部哈希表的wLocale不同如果不加区分列表里看起来像重名文件。我最终在FileListModel中把locale字段拼在文件名后面展示比如“string.zhCN”和“string.enUS”并在提取时用SFILE_OPEN_BY_NAME_AND_LOCALE枚举搜索确保提取的是用户选中的那个语言版本。6.3 超大MPQ的遍历性能《星际争霸2》的patch包文件多时可以到几十万条记录用前面那版遍历接口一次跑完在普通机械硬盘上可能要几十秒界面还会假死。我的优化方案有三层文件列表改用QAbstractTableModel懒加载只向界面提供当前视口内的行数据。把遍历动作放到QThread后台运行期间界面显示进度条避免主线程阻塞。记录哈希索引把文件名缓存到QHash搜索框输入时自动过滤避免每次输入都全表扫描。实测优化后打开一个20万文件的MPQ列表首屏显示时间从原来约8秒降到1秒内搜索过滤也能做到毫秒级响应。6.4 加密文件的乱码现象如果你解出来的文件大小正确但内容明显是乱码八成是MPQ_FILE_ENCRYPTED标志位没被正确处理。StormLib在SFileOpenFileEx时会自动处理加密但前提是你传入的文件名和归档内部存储名完全一致大小写、路径分隔符都不能错。我排查过不少用户的异常反馈最后发现都是因为从别处复制文件名时把反斜杠\变成了正斜杠/导致哈希计算不一致解密失败。所以给文件名做归一化处理很重要QString normalized archivePath.toLower(); normalized.replace(/, \\);7. 一些额外的实操心得工具做完以后我自己用它在本地扫了一圈老游戏的光盘镜像感觉许多细节值得说。如果你只是想快速提取某几个文件对着源码里的extractFile函数改一改就能用如果你想长期维护建议把UI、解析层、数据层分开别把什么都塞进MainWindow里。代码仓库里我还放了一个简单的命令行版本只保留“打开归档、路径匹配、批量提取”三个动作适合服务器环境或不想开GUI的玩家。命令行版本比GUI版本清爽很多本质上就是调用SFileFindFirstFile加SFileExtractFile核心代码不超过一百行。最后说一句个人体会MPQ这套格式能活二十多年还在被使用靠的并不是什么高深算法而是设计上对“快”和“小”的执念。写这个查看器的过程其实也是在跟着StormLib源码补一课关于数据结构设计的课。如果你在阅读源码时遇到某个标志位不懂最快的办法不是查文档而是去翻对应游戏的MPQ文件对照dwFlags数值和实际文件内容比任何说明都直观。本文还有配套的精品资源点击获取
返回列表