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