)
簡介面向游戲開發(fā)者和C編程學習者這份壓縮包提供暴風雪公司MPQ文件查看器及完整源碼。MPQ是《魔獸爭霸》《星際爭霸》等暴雪游戲常用的壓縮格式普通文件管理器無法直接查看該工具能解析并展示MPQ包內(nèi)數(shù)據(jù)開發(fā)者可借此理解文件讀取、解壓、二進制解析和內(nèi)存管理等實現(xiàn)。壓縮包共62個文件約1.29MB含14個頭文件、7個C源文件、3個靜態(tài)庫以及chm幫助文檔、html頁面、bmp/jpg素材和dsp/dsw工程文件結(jié)構(gòu)完整。程序涉及mdx、blp等魔獸3模型與貼圖資源也給出三維數(shù)據(jù)讀取和展示的參考。目前已有254人學習下載適合研究暴雪游戲資源格式、學習C項目實踐或擴展自定義工具的人群。 我大概在2017年左右開始折騰《魔獸爭霸3》的地圖漢化那時候最頭疼的就是怎么把地圖里那一堆模型、貼圖和文本文件從 .mpq 后綴的大文件里拆出來。網(wǎng)上下到的MPQ文件查看器要么不開源、要么在64位系統(tǒng)上各種報錯逼得我最后只能去找一套帶源碼的方案自己改。也就是那段時間我養(yǎng)成了一個習慣拿到這種工具第一件事不是直接雙擊運行而是先把源碼里最核心的解析邏輯讀一遍?!氨╋L雪公司MPQ文件查看器(帶源碼).zip”這個包正好就是這種可以用來學習、也可以拿來改成自己順手的工具的起點。這篇文章我會從MPQ格式本身講起逐步拆解一個查看器程序的核心設計中間穿插一些我實際踩過的坑希望對想搞懂這類打包格式的朋友有幫助。1. 為什么一個十年前的格式還值得動手寫查看器1.1 暴雪游戲資產(chǎn)與MPQ的王牌價值暴雪娛樂從《暗黑破壞神》時代開始用MPQMo’PaQ作為游戲資源的容器格式之后的《星際爭霸》《魔獸爭霸3》《魔獸世界》都在用。它的核心思路其實很樸素游戲運行時要讀成千上萬個文件如果全部散落在磁盤上碎片化嚴重、讀寫效率低打包成單個大文件之后操作系統(tǒng)加載的壓力會小很多。MPQ的設計在當時相當超前它支持高強度加密、文件壓縮、按hash快速查找甚至在《魔獸世界》里還支持“后續(xù)補丁直接覆蓋舊文件而不需要重新打包整個文件”的能力。這也是為什么暴雪游戲這么多年下來MOD作者、地圖漢化組、模型提取愛好者都繞不開它。一個游戲資源打包格式能活二十多年說明它的底層設計是經(jīng)得起推敲的。如果你現(xiàn)在手里握著一個帶源碼的MPQ查看器你其實擁有的不是一個小工具而是一套理解二進制容器格式的完整范本。懂了MPQ再看其他游戲的資源打包格式比如Unity的Bundle、虛幻的PAK思路會順暢很多。1.2 帶源碼的意義不是工具是教材網(wǎng)上能下載到的MPQ工具不少比如StormLib、MPQEditor但真正“帶源碼”且結(jié)構(gòu)清晰的并不多。有源碼的價值在于幾件事你可以改功能比如把“只能查看”改成“支持批量導出”甚至加一個命令行接口。你可以移植到其他平臺比如用C/C寫的解析部分直接編譯到Android或Linux。你可以在讀源碼的過程中搞懂每一個字節(jié)的含義這種理解是看文檔替代不了的。我個人的建議是拿到這類源碼后不要急著編譯先打開核心文件找到三個結(jié)構(gòu)體定義MPQ頭、哈希表項、塊表項。只要這三個結(jié)構(gòu)體看明白了MPQ格式你就已經(jīng)懂了一半。2. MPQ格式的四個核心數(shù)據(jù)結(jié)構(gòu)2.1 文件頭從0x1A51504D開始任何MPQ文件的前4個字節(jié)一定是0x1A 0x51 0x50 0x4D從內(nèi)容上看反著讀就是“MPQ\x1A”。這個魔數(shù)檢查通過后才能繼續(xù)讀后面的字段。一個標準的MPQ文件頭在《魔獸爭霸3》那一代通常是32字節(jié)struct MPQHeader { uint32_t magic; // 0x1A51504D uint32_t headerSize; // 通常為 32擴容版本可能是 48 uint32_t archiveSize; // 整個MPQ包的實際大小 uint16_t formatVersion; // 0 表示經(jīng)典格式1 表示含擴展頭 uint16_t blockSize; // 扇區(qū)大小偏移量以512字節(jié)為單位 uint32_t hashTableOffset; // 哈希表在文件中的偏移 uint32_t blockTableOffset; // 塊表在文件中的偏移 uint32_t hashTableEntries; // 哈希表的條目數(shù)量 uint32_t blockTableEntries;// 塊表的條目數(shù)量 };這里的blockSize不是直接的字節(jié)數(shù)而是表示為512 blockSize。比如值為3時真正扇區(qū)大小是4096字節(jié)。壓縮時數(shù)據(jù)會被切成這么多大小的塊分別壓縮。我第一版代碼就忽略了這個移位導致大文件解壓出來全是亂碼檢查了半天才想起來去看官方定義。2.2 哈希表用查找代替遍歷哈希表是整個MPQ格式的精髓。暴雪設計它的原因很直接如果文件名列表很大每次查找都讀一遍文件目錄性能開銷不可接受。所以MPQ使用了一個固定大小的哈希表通過三個哈希值來定位文件。哈希表的每一項是16字節(jié)struct HashTableEntry { uint32_t name1; // 文件名的第一個哈希值 uint32_t name2; // 文件名的第二個哈希值 uint32_t locale; // 語言區(qū)域zhCN、enUS等 uint32_t platform; // 平臺標記 uint32_t fileBlockIndex; // 對應塊表的索引 };查找過程的邏輯大致是這樣的對文件名做一次哈希得到哈希索引。跳轉(zhuǎn)到哈希表的對應位置檢查name1和name2是否匹配。如果匹配再檢查fileBlockIndex如果值等于0xFFFFFFFE說明該文件已被刪除如果是正常索引就跳轉(zhuǎn)到塊表對應項。如果不匹配線性探測下一項直到遇到空項所有字段為0xFFFFFFFF才算徹底未找到。為什么哈希索引計算要用hash0 (entries - 1)而不是取模因為MPQ頭的哈希表大小固定為2的冪位運算比取??斓枚?。這個細節(jié)看似微小但對大文件頻繁查找來說性能差異很明顯。2.3 塊表真正記錄文件位置的數(shù)據(jù)結(jié)構(gòu)哈希表解決的是“文件名到條目”的映射塊表則記錄“文件數(shù)據(jù)如何在MPQ包中存放”。每一項也是16字節(jié)struct BlockTableEntry { uint32_t filePosition; // 文件數(shù)據(jù)在MPQ中的絕對偏移 uint32_t compressedSize; // 壓縮后大小 uint32_t uncompressedSize; // 解壓后大小 uint32_t flags; // 文件屬性標志 };flags是理解MPQ的關鍵幾個常用位如下標志位值含義MPQ_FILE_IMPLODE0x00000100使用PKWARE DCL壓縮MPQ_FILE_COMPRESS0x00000200使用多算法壓縮ZLib/BZip2等MPQ_FILE_ENCRYPTED0x00010000文件內(nèi)容已加密MPQ_FILE_FIX_KEY0x00020000解密密鑰依賴文件偏移MPQ_FILE_SINGLE_UNIT0x01000000整個文件作為一個壓縮單元MPQ_FILE_DELETE_MARKER0x02000000文件已被刪除MPQ_FILE_EXISTS0x80000000文件真實存在讀取文件時先根據(jù)filePosition把壓縮數(shù)據(jù)讀到內(nèi)存然后判斷壓縮方式。如果flags帶MPQ_FILE_COMPRESS數(shù)據(jù)塊的第一個字節(jié)是壓縮算法組合標識后面才是真正的壓縮流。如果帶MPQ_FILE_ENCRYPTED必須在解壓前先解密。2.4 加密表與密鑰暴雪的反逆向思路MPQ的加密算法雖然不算特別強但思路很有意思。它維護一個0x500個DWORD的表叫做加密表或者Storm表所有文件名哈希和文件內(nèi)容加解密都依賴這張表。加密表的初始化是一個固定的偽隨機過程典型代碼如下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; } } }文件內(nèi)容解密的時候密鑰由文件名哈希和可能的偏移量共同決定。解密過程會不斷更新兩個種子值讓每個8字節(jié)或者4字節(jié)的解密結(jié)果都和前面的結(jié)果關聯(lián)破解起來比較費勁。對查看器開發(fā)來說你不需要重新發(fā)明這套算法但一定要保證加密表初始化正確。如果加密表生成錯誤最明顯的癥狀就是文件名全是亂碼或者listfile文件解出來是一堆無意義數(shù)據(jù)。3. 查看器程序的設計與實現(xiàn)3.1 技術選型解析純二進制C/C依然最直接寫MPQ查看器編程語言的自由度其實很高。C#、Java、Python都能寫但如果你追求性能和二進制操作的直覺C/C是最合適的。C里可以直接用fstream或mmap讀取整個文件然后通過結(jié)構(gòu)體指針直接映射到內(nèi)存效率非常高。用Python的話雖然開發(fā)速度快但逐字節(jié)操作和大量解壓調(diào)用會明顯慢尤其是處理像《魔獸世界》動輒幾十GB的MPQ文件時體驗差距很大。我看到過不少帶源碼的MPQ查看器都是基于C寫的配合Qt或者Win32做界面。如果你只是學習原理可以不關心界面做一個命令行工具專注實現(xiàn)三個核心接口class MPQArchive { public: bool Open(const char* path); bool GetFileList(std::vectorstd::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_castchar*(header_), sizeof(header_)); // 檢查魔數(shù) if (header_.magic ! 0x1A51504D) { return false; } // 讀取哈希表 hashTable_.resize(header_.hashTableEntries); file.seekg(header_.hashTableOffset); file.read(reinterpret_castchar*(hashTable_.data()), header_.hashTableEntries * sizeof(HashTableEntry)); // 讀取塊表 blockTable_.resize(header_.blockTableEntries); file.seekg(header_.blockTableOffset); file.read(reinterpret_castchar*(blockTable_.data()), header_.blockTableEntries * sizeof(BlockTableEntry)); return true; }打開文件這一步其實沒什么難度真正的坑在后面的查找和解壓。3.3 文件列表的獲取策略MPQ里的文件名默認不會直接暴露出去而是存儲在一個名為(listfile)的內(nèi)部文件里這個文件本身可能被加密。查看器通常有兩種方式獲得文件名列表優(yōu)先嘗試從(listfile)文件讀取。讀取流程和普通文件一樣先哈希查找再讀取內(nèi)容如果加密則解密最后按文本行解析。如果(listfile)不存在或者為空就只能暴力掃描哈希表。把哈希表里所有有效項遍歷一遍但只能得到哈希值和塊表索引拿不到明文文件名這時候需要配合外部詞典或者人工猜測。我在做地圖漢化時經(jīng)常遇到的情況是某些第三方地圖的作者故意刪掉了(listfile)這時候查看器列表里只有一片“文件1”“文件2”這樣的占位名。處理辦法是把地圖文件和原版游戲目錄的同名文件比對通過文件大小和壓縮方式推斷內(nèi)容。3.4 解壓模塊需要處理哪些算法MPQ歷史上用過的壓縮算法不少查看器不可能只依賴一種。常見的有壓縮算法常見標識實現(xiàn)建議ZLib0x02各語言都有現(xiàn)成庫直接用BZip20x08也建議直接用庫Huffman0x01暴雪自定義的Huffman變體需要自己實現(xiàn)PKWARE DCL0x04老版本暗黑/星際常用庫較少LZMA0x10《魔獸世界》用得多可用liblzmaWarcraft III版本的地圖通常用的是ZLib和BZip2配合Huffman。寫查看器的時候我建議先支持ZLib因為這個覆蓋了大多數(shù)場景然后再按需求補充其他算法。解壓時的關鍵點是不要對整份數(shù)據(jù)直接解壓而是先看壓縮塊的第一個字節(jié)根據(jù)壓縮標識決定解壓策略。比如標識是0x02直接對剩余數(shù)據(jù)做ZLib inflate如果標識是0x03說明先用Huffman解壓再做ZLib解壓順序不能反。4. 實機演示從War3地圖中提取模型與貼圖4.1 準備一個真實存檔理論說再多不如實際跑一遍。我以一份普通的魔獸爭霸3自定義地圖為例文件名叫test.w3x。實際上.w3x本身就是一個MPQ格式的容器只是后綴名不同所以直接用查看器打開即可。打開后先看一下文件列表你會看到類似這樣的內(nèi)容war3map.j war3map.wtg war3map.wts war3map.doo war3map.shd war3mapMap.blp這些是地圖的邏輯腳本、觸發(fā)數(shù)據(jù)、文本字符串和地圖預覽圖。對于漢化工作來說最需要提取的是war3map.wts里面保存了所有界面文本。提取出來之后用文本編輯器打開可以直接修改單位名稱、技能說明然后找工具回寫到地圖里。4.2 提取指定文件并驗證提取文件的流程分幾步走用HashString計算目標文件名在哈希表里的索引。找到匹配的哈希表項拿到fileBlockIndex。根據(jù)fileBlockIndex查塊表拿到偏移、壓縮大小和標記位。如果文件加密用文件名加上塊偏移計算密鑰先解密。根據(jù)壓縮標記決定是否解壓寫成輸出文件。以war3mapMap.blp為例這是一個貼圖文件提取出來后可以直接用圖片查看器打開。如果打開報錯多半是解壓或解密步驟出了問題而不是提取本身的問題。判斷方法是用十六進制編輯器看文件前幾個字節(jié)BLP貼圖的魔數(shù)通常是BLP1或BLP2如果文件頭不對說明數(shù)據(jù)在處理過程中已經(jīng)損壞。4.3 實操過程中的小意外我遇到過一個很典型的問題列表里有文件名文件大小也對但提取出來永遠是0字節(jié)。后來查了半天發(fā)現(xiàn)那份地圖的塊表項flags里帶有MPQ_FILE_DELETE_MARKER文件在邏輯上已經(jīng)被刪除了只是在哈希表里還殘留著條目。查看器如果沒判斷這個標志位就會試圖提取一塊壓根不存在的數(shù)據(jù)。所以說寫提取邏輯時flags的判斷一定要完整尤其是MPQ_FILE_EXISTS、MPQ_FILE_DELETE_MARKER、MPQ_FILE_ENCRYPTED這三個少一個都可能讓你調(diào)試到懷疑人生。5. 開發(fā)與調(diào)試中的五個關鍵避坑方向5.1 用二進制編輯器先看再寫代碼我強烈建議在動手寫任何解析代碼之前先用HxD或者010 Editor打開一個真實的MPQ文件對著文件頭、哈希表、塊表逐段查看。只有親眼看到那些字節(jié)的排列你才能真正理解為什么hashTableOffset是從文件頭開始的偏移量為什么哈希表項在內(nèi)存里是連續(xù)排列的。很多新手一上來就照著別人的源碼抄抄完發(fā)現(xiàn)讀出來的數(shù)據(jù)不對其實是因為沒理解某些字段和偏移的對應關系。紙上得來終覺淺二進制編輯器就是MPQ入門最好的老師。5.2 加密表初始化必須逐字校驗加密表初始化這件事看起來只是一個循環(huán)和幾個魔數(shù)但里面任何一個常數(shù)寫錯都會導致后面所有哈希計算和解密全部錯誤。更難受的是錯誤不是立刻顯現(xiàn)的而是隱藏在某些文件里可能5分鐘后才暴露。我建議把加密表生成代碼單獨抽出來寫一個單元測試把生成結(jié)果的前幾個值和公開源碼對比。很多帶源碼的MPQ項目里都有現(xiàn)成的參考實現(xiàn)直接對照一目了然。5.3 壓縮標志位不能只判斷一位MPQ的flags是一個32位整數(shù)多種屬性可以同時存在一個文件既可以是加密的也可以是壓縮的還可以是分塊存儲的。有些人寫代碼時只判斷某一個位其他位直接忽略這會導致在某種文件上偶發(fā)解壓失敗。正確的做法是每處理一個文件都完整檢查所有相關位形成清晰的流程分支。用現(xiàn)代調(diào)試器做條件斷點也會省很多時間。5.4 ArchiveSize與真實文件大小的差異MPQ頭里的archiveSize理論上表示整個MPQ包的大小但實際文件可能因為補丁等原因末尾多出幾百字節(jié)數(shù)據(jù)。我第一次用archiveSize作為文件讀取總長度時程序報錯說“讀取越界”后來改成直接用std::ifstream的seekg到文件末尾獲取真實大小才徹底解決。所以每次打開文件后建議讀取真實文件大小并和archiveSize做一次對比。如果差異較大至少打印一條警告日志方便后續(xù)排查。5.5 版本兼容別忽略擴展頭《魔獸爭霸3》和《魔獸世界》經(jīng)典版本用的是32字節(jié)文件頭但后來出現(xiàn)了帶擴展頭的MPQheaderSize可能大于32甚至包含64位的大文件偏移。如果你的查看器只按32字節(jié)結(jié)構(gòu)體解析在高版本文件上很可能會定位到錯誤的位置。一個比較穩(wěn)妥的做法是讀頭信息時不要直接用固定結(jié)構(gòu)體覆蓋而是先讀取前32字節(jié)然后根據(jù)formatVersion和headerSize再決定是否繼續(xù)讀擴展部分。最后再分享一個小技巧調(diào)試MPQ解析最有效的辦法不是打斷點而是做一個“對比模式”用已知好用的工具比如MPQEditor導出某個文件的原始數(shù)據(jù)和屬性再讓你的程序輸出同樣的信息逐字段對比。差異出現(xiàn)在哪里問題就在哪里。這套帶源碼的MPQ文件查看器我拿來做的最值的一件事就是把它改造成了一個支持批量導出和命令行調(diào)用的工具配合寫地圖MOD的流程效率提升了不止一倍。對于想深入理解游戲資源格式的人來說把MPQ吃透等于拿到了一把通用鑰匙之后再碰其他容器格式都會覺得親切很多。本文還有配套的精品資源點擊獲取