MPQ技术内幕

开始尝试翻译一些英文文章,最近正好对mpq产生兴趣,看到一片文章叫做 inside MPQ,于是翻译一下,就当锻炼自己吧。这篇文章非常的不厚道,在关键地方戛然而止,而且没有更新的迹象。让人郁闷无比。但是还是比国内一些研究MPQ的少的可怜的文章要好些。看了这些文章,无比惋惜国内技术的滞后和黑客技术,逆向工程技术的贫乏。我们总是拿来主义,做应用。自己的原创真的太少了。

LEGAL COPYRIGHTS

The MPQ Format The copyrights to the MPQ format are held by Havas Interactive, Blizzard Entertainment's parent company, all rights reserved This Article The copyrights to this document and content are held by Justin Olbrantz(Quantam), all rights reserved. You may freely distribute this document provided that you do not derive profit from the distribution, and that the document remains complete and unchanged. You may quote this document ONLY with my explicit permission. Contact me to obtain permission to quote.Also, although I would appreciate recognition for your use of this information, I will not be held legally responsible for anything you may do with it. Anyway that you misuse this information is your problem, and I will not be responsible for it.

 

这个LEGAL COPYRIGHTS我就不做翻译了。a

对于我这篇翻译的文章,申明如下:

可以转载,但要注明作者是王宇,并且保证整个内容包括上面几段内容的完整性。并且我对一切后果不承担责任。

MPQ 技术内幕
作者 Justin Olbrantz(Quantam)
译者 王宇

第1章

MPQ简介

MPQ 或者称作 MoPaQ 是Mike O'Brien创建的拥有私人版权的档案文件格式。Mike O'Brien是暴雪公司的多人游戏引擎方面的天才。他在1996年,为了暗黑破坏神而开发出这种档案文件格式。并且自恋的以自己的名字“Mike O'brien PaCK”给这种格式命名MPQ。但是文档的版权却由Havas Interactive(暴雪的父公司)所有。所以,即使现在Mike离开了暴雪,暴雪仍然拥有MPQ格式的使用权。MPQ格式在暗黑破坏神,星际争霸,魔兽争霸2,3,暗黑破坏神2,BNE(译者备注:我不知道这是什么游戏),Lords of Magic(由sierra公司开发,这个公司同样隶属于Havas)等游戏中都有应用。

一个档案文件是指一个包含其他文件在内的文件,并且它经常是以压缩的形式存在的。Havas用MPQ包含了游戏中几乎所有的东西。比如安装文件,游戏数据等等。其中游戏数据的MPQ封装是非常重要的。这些MPQ当中包括了图像,声音,等级,字符串,故事线信息等等。Obviously, the potential for customization is astounding. (译者备注:这句不好翻)但是,为了用MPQ,你必须首先理解它。

在MPQ之前

在MPQ发明之前很长一段时间,有一种个是叫做WAR(Warcraft ARchive)格式。这种格式是在魔兽争霸2甚至1中存储数据的格式。这种雏鸟格式非常的简单,也没有优化,总是看起来就是一个实实在在的新手文件格式。档案中的文件是按照坐标来寻址的,唯一的一点点优化就是用了一些压缩技术。但是,虽然它简单,它完成了它需要完成的任务。它提供了一种快速但是肮脏的方法压缩的存储了很多文件。但是不久,缺点就开始暴露出来了。按照坐标来寻址意味着必须保存一个很长的入口表来供程序员使用档案中某些文件的时候调用。当这个表越来越长的时候,工作就变得越来越冗长。而且这种简单的格式意味着黑客可以很容易的在15分钟内破解除这种格式,然后可以随心所欲的在这些文件上做一些事。这些问题一开始看起来可能还不太糟,但是当暗黑破坏神所要求的persistent characters(译者备注:这个我不懂),站网的普及让这些问题变得无法接受了。

为什么是MPQ

正如前面所说,MPQ格式是为了弥补一些WAR非常严重的缺陷设计的。但是它仍然添加了很多新的特性。总的说来,MPQ的特点如下:

安全性:暴雪最不愿意的就是人们象破解魔兽争霸2那样破解它以后的游戏。而且暴雪很可能已经觉得要把MPQ格式应用到星际争霸上面。不管怎么样,安全性是最最重要的。这点可以从那些暴雪维护这种格式的折磨人的努力中看出来。

效率:MPQ需要完成一系列工作,从最简单的预读数据到复杂的实时流。对于预读数据倒还没什么,但是对于实时流,因为数据必须以很快的速度一边玩游戏一边解压缩,所以,速度是强制的。

多语言:在最一开始,暴雪就计划把它的产品推向世界市场,所以,它希望它的游戏的翻译能尽量容易。于是它用了一种革新的方法,就是把多语言性的本领放在MPQ格式里面。

可扩展性:很显然的,把一个游戏所有的数据放入一个档案是很傻的。不仅没有效率,速度很慢,而且售后升级会变得非常麻烦。暴雪当然知道这点,因此,为了使售后升级简单,有效,优雅,它在MPQ格式的设计上就考虑到了这个问题。

 

风暴 Storm

很多程序员为了防止冗余代码,通常会把一些常用的代码封装到共享库里面。这些共享库可以提供程序员常用的函数。这样可以减少冗余和程序体积。所以,暴雪用一个共享库叫做Storm(在微软平台上叫做Storm.dll, 在苹果平台上叫做Storm.bin)这个库被现在的暴雪游戏用来储存重要函数,比如MPQ的读入,战网,甚至是图像路由。当暴雪发布一个新游戏的时候,它会在storm里面加入函数,但是不会修改旧的函数。这意味着一个老的游戏可以用新的Storm库而不会出问题。像任何共享库一样,Storm的函数可以被任何人使用,这样就使它的安全性变得很差。这就是Storm只包含MPQ的读取函数而MPQ的写入函数却是暴雪的私人财产,它不会允许任何人去使用的原因了。

星际争霸的任务编辑器

大家都知道星际争霸的任务编辑器可以编辑任务。但是星际争霸的任务就是MPQ!这意味星际的任务编辑器可以创建MPQ,所以其中有MPQ的创建函数。不过星际争霸的任务编辑器不是一个共享库,所以要用一系列诡异的黑客技术去破解它。于是有了MPQ API 库。

 

第2章

基础

大多数计算机历史上的进步是因为有特殊的问题需要解决。在这章,我们将了解一下关于MPQ格式的问题和它们的解决方案。

哈希

问题:你有一个很大的字符窜数组。你有另一个字符窜str需要判断是否存在于这个数组里面。可能你就会按照顺序一个一个的比较数组里面的内容。但是在实际应用中,你会发现这种方法远慢于实际需求。必须对此做一些优化。但是如何你才能知道这个字符窜是否存在却不用把它同数组中的所有其它字符窜比较呢?

解决方案:哈希。哈希是用来代替大一些的数据类型(比如字符窜)的小一些的数据类型(比如数字)。在我们这个问题里,你可以把字符窜数组储存为哈希数组。然后你就可以比较另外的那个字符窜str的哈希同储存的哈希数组中所有的哈希。如果哈希数组中的一个哈希同str的哈希匹配,那么这个哈希所代表的字符窜就可以同str进行比较来判断到底是否相同。这种方法叫作下标(indexing),根据数组大小和字符窜长度的不同,它可以把速度提升将近100倍。

unsigned long HashString(char *lpszString)

    unsigned 
long ulHash = 0xf1e2d3c4;

    
while (*lpszString != 0)
    { 
        ulHash 
<<= 1;
        ulHash 
+= *lpszString++
    }

    
return ulHash; 

 以上的代码展示了一个非常简单的哈希算法。函数计算了字符窜中的字符个数,在每个字符加入之前把哈希值左移1位。应用这个算法,字符窜"arr\units.dat"将会被哈希成0x5A858026,而"unit\neutral\acritter.grp" 将会被哈希成0x694CD020。不可否认,现在这个算法非常的简单,而且没有什么用处。因为它产生了一个相对可以预见的结果。而且会有很多冲突。chogntu 是指多个字符窜哈希到同样一个数值。 而另一方面,MPQ格式却用了一种非常复杂的哈希算法(如下所示)去生成一个完全不可预料的哈希值。事实上,这种哈希算法叫做单行道哈希(one-way hash)。单行道哈希是指根据哈希值不能推回去找到源字符窜的哈希算法。应用这种MPQ算法,文件名"arr\units.dat" 将被哈希为0xF4E6C69D,而"unit\neutral\acritter.grp"将被哈希为0xA26067F3.

unsigned long HashString(char *lpszFileName, unsigned long dwHashType)

    unsigned 
char *key = (unsigned char *)lpszFileName;
    unsigned 
long seed1 = 0x7FED7FED, seed2 = 0xEEEEEEEE;
    
int ch;

    
while(*key != 0)
    { 
        ch 
= toupper(*key++);

        seed1 
= cryptTable[(dwHashType << 8+ ch] ^ (seed1 + seed2);
        seed2 
= ch + seed1 + seed2 + (seed2 << 5+ 3
    }
    
return seed1; 

 

 哈希表

问题:你试图使用之前例子里面的下标法,但是你的程序需要非常严格的速度限制。这时候你就会发现下标法不够快了。这时候你让它变得更快的方法只能是不让它检查数组中所有的哈希。或者,更好的是只让字符串同数组中的某个元素比较1次就能判断出这个字符窜是否存在于这个数组。听起来太好了以至于不可能对不对?

解决方案:哈希表。哈希表是一种下标为字符串哈希值得数组。我的意思是说,我们为这个哈希表构建一个不同于字符串数组的定长数组(我们把它的元素个数定位1024,2的偶数次幂)。这时候,当你想要知道一个字符串是否在哈希表中时,你得首先计算这个字符串如果在哈希表中,那么它的位置是多少。首先我们计算这个字符串的哈希,然后用哈希模取之前的表长(1024)就得到了位置值。因此,如果你用之前的简单哈希算法,"arr\units.dat"将被哈希为0x5A858026,得到它得位置值为 0x26 (0x5A858026 模取 0x400 商为 0x16A160余数为0x26)。0x26这个位置的字符串(如果有的话)将被读出来与目标字符串比较。如果0x26这个字符串与目标字符串不匹配或者0x26的这个字符串不存在,则这个目标字符串不存在于这个数组中。以下的代码说明了这点:

int GetHashTablePos(char *lpszString, SOMESTRUCTURE *lpTable, int nTableSize)

int nHash = HashString(lpszString), nHashPos = nHash % nTableSize;
if (lpTable[nHashPos].bExists && !strcmp(lpTable[nHashPos].pString, lpszString)) 
return nHashPos; 
else 
return -1//Error value 

可是现在,这个算法有一个巨大的缺陷。你认为当冲突(2个字符窜哈希到同样一个值)发生的时候会怎么样?显然它们不能占用哈希表中的同一个元素。一般,这种缺陷通过使哈希表中的每一个元素成为一个链表来实现。每个链标中将存放哈希值相同的字符窜。MPQ使用文件名哈希表来跟踪内部的所有文件。但是这个表的格式与正常的哈希表有一些不同。首先,它没有使用哈希作为下标,把实际的文件名存储在表中用于验证,实际上它根本就没有存储文件名。而是使用了3种不同的哈希:一个用于哈希表的下标,两个用于验证。这两个验证哈希替代了实际文件名。当然了,这样仍然会出现2个不同的文件名哈希到3个同样的哈希。但是这种情况发生的概率平均是1:18889465931478580854784,这个概率对于任何人来说应该都是足够小的咯。MPQ哈希表不同用通常的链表冲突解决法,当冲突发生时,元素将被下移到下一个空着的位置。请看下面的代码,基本就是MPQ定位文件名的方法:

int GetHashTablePos(char *lpszString, MPQHASHTABLE *lpTable, int nTableSize)

    
const int HASH_OFFSET = 0, HASH_A = 1, HASH_B = 2;
    
int nHash = HashString(lpszString, HASH_OFFSET), 
        nHashA 
= HashString(lpszString, HASH_A), 
        nHashB 
= HashString(lpszString, HASH_B), 
        nHashStart 
= nHash % nTableSize,
        nHashPos 
= nHashStart;
    
while (lpTable[nHashPos].bExists)
    { 
        
if (lpTable[nHashPos].nHashA == nHashA && lpTable[nHashPos].nHashB == nHashB) 
            
return nHashPos; 
        
else 
            nHashPos 
= (nHashPos + 1% nTableSize;

        
if (nHashPos == nHashStart) 
            
break
    }
    
return -1//Error value 

虽然这段代码可能看起来让你费解,但是它背后的理论却并不复杂。它在读取一个文件的时候基本遵循了以下的步骤:

1 计算3个哈希(1个下标哈希和2个检查哈希)并且把他们存入变量
2 移动到下标哈希所指的元素
3 这个元素存在吗?如果不存在,停止搜索,返回“文件没有找到”
4 元素的两个检查哈希是否我们搜索的文件的检查哈希相匹配?如果相匹配,就返回当前的元素。
5 移动当前下标到下一个,如果达到最后一个下标,则回到第1个
6 我们刚一动到的元素的下标哈希是否相同(我们是否搜索了整个表)如果是,停止搜索,返回“文件没有找到”
7 回到第3步

如果你留心了,你会发现,在我的解释和例子中MPQ哈希表需要保存所有的文件名。但是,你有没有想过当所有的哈希表行全部都填满的时候会发生什么?答案可能会让你非常惊讶:你将不能再添加任何文件。有人问我为什么一个MPQ会有文件数目限制,有没有什么方法可以解决这种限制。你已经直到第一个问题的答案了,对于第2个问题,很遗憾,你不能解决这种文件数目限制。因为哈希表不能再不影响整个文件改变的情况下改变大小。这是因为哈希表中每个元素的哈希都因为哈希表大小的变化发生改变,这样我们就不能得到文件在新的哈希表中的位置,于是我们就不能得到文件名了。

压缩

问题:你有一个很大的程序(比如50MB)你现在希望把它发不到Inter网上。但是50MB将会是非常大的下载,人们可能就不会愿意等上几个小时去下载这么一个东西。

解决方案:压缩。压缩是指把一大堆数据用一种很小的格式表达出来。世界上有很多种压缩算法,每一种都用不同的方法工作。而我们的MPQ使用的数据压缩算法是PKWare的数据压缩库。而这个库在这里解释的话就太复杂了。所以,我在这里想解释一种相对简单的夺得压缩算法。
此节因为作者的能力原因,没有完成。

 

加密

一个系统对于间谍之眼窥视的防护一直是永恒的话题。人们已经努力传送私人信息给别人了上百年。从古希腊信使步行传送的手写书信到2战时纳粹潜艇的无线电,再到今天网络信用卡交易。保证别人不能得到你的信息的能力是非常必要的。这种复杂的保护方法叫做加密。虽然我们不知道第一个加密算法是谁发明的,但是我们知道世界上游多的数不过来的加密算法。任何事物,从简单的数据编码到解密算法都是被使用了一次又一次的。这篇文章,当然没有解释,也不期望解释一个加密算法,但是理解加密是你接触MPQ工作的必须。

我们首先来看一个发布在 Basic Lab Notes上的加密算法:

void EncryptBlock(void *lpvBlock, int nBlockLen, char *lpszPassword)

    
int nPWLen = strlen(lpszPassword), nCount = 0;
    
char *lpsPassBuff = (char *)_alloca(nPWLen);
    memcpy(lpsPassBuff, lpszPassword, nPWLen);
    
for (int nChar = 0; nCount < nBlockLen; nCount++)
    { 
        
char cPW = lpsPassBuff[nCount];
        lpvBlock[nChar] 
^= cPW;
        lpsPassBuff[nCount] 
= cPW + 13;
        nCount 
= (nCount + 1% nPWLen; 
    }
    
return

正如展示的哈希代码那样,这段代码也非常的简单,当然也就不能用在需要安全性的实际程序中。即便这段代码看起来很神秘,它做的事情却非常简单。它将整个的输入块加密。异或密码的每一个字节。然后把所得加上13(之所以选择13是因为13是质数)。这样就能够使代码更加难以确认。在这种情况下,字符串"encryption" (65 6E 63 72 79 70 74 69 6F 6E)在密码"MPQ" (4D 50 51)下将会被加密成为(28 3E 32 28 24 2E 13 03 04 1A)现在,这段代码是对称的。对称意味着加密的密钥和解密的密钥是相同的。实际上,因为异或是一个对称的操作,所以同加密相同的算法可以被用来解密。注意到大部分对称加密算法并非完全对称,所以需要加密和解密的函数不相同。好,现在事情开始变得麻烦了。如果你希望直接的使用MPQ格式,那么你必须知道它的加密和解密算法。而我就来教你如何使用它.MPQ的加密算法是一些其他加密算法有趣的杂交。它创建一个加密表(也用在哈希函数里面),然后用一个文件的加密钥去从加密表中去除某些数字,再把这些数字同加秘数据进行异或。现在这种做事的方法是非常非常奇怪的,所以可能一些代码看起来非常的复杂。以下的代码生成一个长度为0x500的加密表。

 

void prepareCryptTable()

    unsigned 
long seed = 0x00100001, index1 = 0, index2 = 0, i;
    
for(index1 = 0; index1 < 0x100; index1++)
    { 
        
for(index2 = index1, i = 0; i < 5; i++, index2 += 0x100)
        { 
            unsigned 
long temp1, temp2;
            seed 
= (seed * 125 + 3% 0x2AAAAB;
            temp1 
= (seed & 0xFFFF<< 0x10;
            seed 
= (seed * 125 + 3% 0x2AAAAB;
            temp2 
= (seed & 0xFFFF);
            cryptTable[index2] 
= (temp1 | temp2); 
        } 
    } 
}

你是否有点感觉到暴雪雇佣了一个超级没有人品的微积分教授撰写了这个代码?至少我是这么感觉的。还好即使你不能看懂这段代码也没有什么大问题。如果你希望能够直接使用MPQ,那么你可能会需要这些函数。你没有必要完全看明白他们。不管怎么样,当加密表初始化以后,我们就可以用下面的函数来解密MPQ数据(不要指望我会向你解释这个代码,因为我也没有看懂):

void DecryptBlock(void *block, long length, unsigned long key)

    unsigned 
long seed = 0xEEEEEEEE, unsigned long ch;
    unsigned 
long *castBlock = (unsigned long *)block;
    
// Round to longs
    length >>= 2;
    
while(length-- > 0)
    { 
        seed 
+= stormBuffer[0x400 + (key & 0xFF)];
        ch 
= *castBlock ^ (key + seed);
        key 
= ((~key << 0x15+ 0x11111111| (key >> 0x0B);
        seed 
= ch + seed + (seed << 5+ 3;
        
*castBlock++ = ch; 
    } 
}

翻译后记:

这只是我闲来无事翻译着玩的东西,都没有认真的推敲翻译的语句,甚至有一些语句我是没有看懂的,或者明明知道这样翻译是不好的但还是写上去了。甚至我都没有兴趣自己从头到尾把这篇文章再看1遍。之所以只翻译道第2章是因为第3,4章分别讲述Storm和Starcraft Campaign Editor and the MPQ API Library是如何使用的,没有什么翻译的价值。而真正精彩的5,6两章作者又没有写完。所以说作者实在不厚道。一下给出英文源出处,希望我的翻译只是抛砖引玉,能激发大家越读英文原版的激情。很多时候翻译的过程中损失的信息还是相当严重的。

posted @ 2011-12-30 17:55  小 楼 一 夜 听 春 雨  阅读(2779)  评论(0编辑  收藏  举报