拿到一个几十个 G 的资源包,双击打开一看全是乱码,文件头是四个谁也看不懂的字节,索引表藏在一堆加密数据中间——这个场景,只要你接触过游戏资源逆向、老软件素材抢救、或者单纯想从某个包里把配音和贴图扒出来,就一定遇到过。QuickBMS 就是在这个环节里被我用了很多年的工具:一个命令行式、脚本驱动的通用提取器,配套脚本库里堆着两百多种格式的解析方案,Windows、Linux、macOS 上都能跑,遇到新格式还能自己写脚本现场解。它不负责“一键变漂亮”,也不做模型预览,它只干一件事:把二进制包按你描述的规则拆开,还原成一个个独立文件。这篇内容我打算把它的用法、BMS 脚本的写法、我自己踩过的坑,以及解包过程中最容易卡住的排查路径完整讲一遍。适合刚入行想搞懂“包体结构”的新手,也适合已经会用、但每次遇到新格式就只能干等的朋友——看完你至少能做到:拿到一个陌生包,知道从哪儿下手量结构,能写出第一版能跑的脚本。
1. 为什么十年过去了我还在用它拆包
1.1 面对未知格式时,脚本驱动比点选式更抗打
市面上大部分提取工具是“格式绑定”的:作者针对某一个引擎、某一类包体写好了解析逻辑,你打开软件、点“打开”、选文件、点“提取”,成了。这种工具的体验当然好,但它的天花板也很明显——换个引擎、换个版本号、换一层压缩,立刻失效,而且你没有任何插入点,只能等作者更新。
QuickBMS 的设计思路完全反过来。它的主程序几乎是“空”的,不认识任何具体格式,只认识一套指令语法;真正决定“怎么拆”的是脚本。这就带来一个很实际的好处:格式变了,你不必等主程序升级,改脚本就行。我遇到过某款老包体在新版本里加了一个 8 字节的校验字段,索引整体后移,用固定逻辑的工具全部报错,而我只在脚本里加了一句math和一个goto,五分钟解决。
这背后的成本也很清楚——你得懂一点二进制,看得懂十六进制。所以它不适合“只想拿素材、不想学原理”的人,但对想把这件事搞明白的人来说,性价比极高。它的价值不在于“支持多少格式”,而在于它把“解析未知结构”这件事变成了一门可以现学现用的手艺。
1.2 200+格式的真实含义
宣传里说的“两百多种格式”,指的是官方脚本库里随包分发的.bms脚本数量。这里要泼一盆冷水:这个数字不等于“两百多款游戏都能拆”。实际情况是,很多脚本覆盖的是同一引擎的多个变体,有些脚本只针对特定版本的封包,还有些是社区早期贡献、年久失修,跑起来直接报错。
我一般的判断顺序是这样的:先在脚本库里按扩展名和特征字符串搜一遍,找到最接近的那个当模板;跑一次,看是“完全读不出文件列表”还是“列表对了但文件内容不对”。前者通常是魔数或头部长度对不上,后者多半是压缩算法或偏移基准的问题。两种情况都能在模板基础上改,比从零写快得多。
注意:脚本库里的脚本建议复制一份到自己工作目录再改,不要直接改原文件。后续换机器或者更新库的时候,你的改动很容易被覆盖掉,回头再找就找不回来了。
2. 工具链拆解:主程序、脚本库与配套
2.1 主程序到底做了什么
主程序本质上是一个带脚本解释器的文件读取器。它负责几件事:打开归档文件、按脚本指令在文件里移动读写指针、把读到的字节塞进变量、把结果写到输出目录。至于“这几个字节是偏移还是长度”“这段数据要不要解压”,全靠脚本告诉它。
这也解释了一个新手常见困惑:为什么我双击主程序,它闪一下就没了?因为它是命令行工具,没有交互界面,参数不够就直接退出。正常的调用形式是把脚本、归档、输出目录三个参数按顺序传进去。Windows 下建议开一个终端窗口,把 exe 拖进去再补参数,比在资源管理器里来回切目录省事得多。
它支持的常见参数里,我常用的有几个:-o覆盖已存在的输出文件,-Y对所有询问自动回答“是”,-l只列文件不提取,-f按名字过滤只处理匹配项,-d保留目录结构。批量跑的时候-o -Y基本是必带的,不然脚本一多,每个文件都弹一次确认,一晚上都跑不完。
2.2 脚本库与自写脚本的取舍
脚本库是“存量”,自写脚本是“增量”。我的习惯是:先用库里的脚本跑通一个样本,确认基础结构,再决定要不要自己重写。因为库里的脚本往往包含了原作者处理各种边界情况的补丁,比如某些条目的偏移是相对段首而不是文件头、某些字段在大端小端之间来回切,这些都是踩过坑才会写进去的。
但也不能迷信。库脚本最大的问题是可读性差,很多人写脚本时只求能跑,变量名全是a1、a2,中间夹一堆if分支处理特例。等你需要改的时候,基本等于重新逆向一遍。所以我的做法是:跑通之后,用库脚本当参考,自己按“先读头、再读索引、最后逐条导出”的三段式重写一遍,变量名写清楚,注释写明白。这份重写稿才是你自己的资产。
2.3 平台与版本选择
Windows 上有两个主程序,一个常规版,一个面向超大文件的版本。区别在于后者对超过 4GB 的归档和单个超大文件支持更好,代价是某些老脚本可能不兼容。我一般的判断是:归档体积超过 4GB 就直接用大文件版,否则用常规版,出问题再换。
Linux 和 macOS 上有原生编译的版本,用起来和 Windows 完全一致,脚本通用。这一点在我做批量处理时特别有用——把脚本和归档丢到一台常开的机器上,写个循环一次跑完,比在本地一个个点要稳。命令行参数和 Windows 版一致,注意路径里的反斜杠要换成正斜杠,输出目录提前建好,不然有些脚本会因为目录不存在直接中断。
3. BMS 脚本语法:把二进制结构翻译成指令
3.1 从一句 idstring 开始
任何包体解析的第一步都是确认“这是不是我要的格式”。脚本里干这件事的指令是idstring,它把文件当前位置的若干个字节和给定的字符串比对,不匹配就报错退出。这一步看似简单,却是避免“拿错脚本跑错包”的第一道闸门。
写法上,能直接打出来的 ASCII 就写字符串,不可见字节用十六进制转义拼进去。比如文件头是MPAK加两个保留字节,就写成idstring "MPAK"再补一次读取。如果魔数里有0x00这种字符,转义写法是必须的,否则字符串会被提前截断,比对永远失败。
这里有个我踩过的坑:有些包体前面挂着一段可执行文件头或者填充块,真正的魔数在偏移几百字节之后。这时候不能硬改idstring,而要先goto跳到目标位置再比对。写脚本时养成习惯,先goto 0再idstring,中间如果需要跳转,用注释标清楚跳了多少、为什么跳。
提示:不确定魔数在哪儿时,用十六进制编辑器打开文件,搜已知的引擎特征字符串,比一个个猜偏移快得多。
3.2 读、跳、存:get / goto / savepos
这三个指令构成了脚本的骨架。get负责从当前位置读若干字节到变量里,读多少取决于类型:单字节、双字节、四字节、八字节,或者带长度的字符串。goto把读写指针挪到指定位置,支持相对当前、相对文件头、相对文件尾三种基准。savepos把当前位置记进一个变量,方便后面回头。
变量在 BMS 脚本里不需要声明,第一次get就自动创建。这一点很方便,但也容易出隐蔽问题:变量名拼错不会报错,只会读到空值或者沿用上一次循环的旧值,导出的文件大小莫名其妙。我的习惯是所有变量名一律小写下划线,读一次就写注释标明含义,比如get data_offset long后面紧跟一行注释说明这是相对文件头的绝对偏移。
字节序是另一个高频出错点。默认是小端,遇到大端结构要在读之前用endian big切换,读完切回来。忘了切的表现是:读出来的偏移数字大得离谱,或者文件数量是个几百万的荒谬值。看到这类数字,第一反应就该是字节序。
3.3 循环与条件:把索引表遍历出来
包体的索引通常是一张表,每条记录包含偏移、压缩后大小、原始大小、名字长度和名字本身。逐条读出来需要循环,BMS 里用for ... next结构,语法和常见脚本语言类似。循环变量从 0 开始,条件写小于总数。
条件分支用if / else / endif,最常见的用途就是判断这条记录是“直接存储”还是“压缩存储”。判断依据一般就是压缩后大小和原始大小是否相等:相等说明没压缩,直接复制;不等说明走了压缩,需要按对应算法解出来。
这里有个细节值得单独说:循环里读名字的时候,名字长度字段在每条记录里的位置是固定的,但名字本身的长度是变的。所以不能一次性把所有记录按固定长度读进来,必须逐条读、逐条处理。很多人图省事,先把索引整体读进一个大字符串再切,结果因为长度不齐全乱套。
3.4 压缩与导出:log 与 clog 的区别
导出指令有两个,区别必须搞清楚,否则解出来的文件全是坏的。log是原样复制:给它名字、源偏移、字节数,它就把那段字节直接写到输出文件里。clog是解压复制:多给一个参数,告诉它这段数据解压后应该有多大。
用哪个取决于数据本身。判断方法前面说过,比较两个大小字段。但有一种情况会骗人:某些包体会把已压缩的数据再套一层存储,两个大小字段相等,实际内容却是压缩流。这种只能靠试——用log导出来打不开,换成clog指定算法就能读,那就说明是这种情况。
压缩算法通过comtype指令设定,常见的 zlib、deflate、LZMA、LZO 都在支持范围内,具体写哪个要看包体实际情况。设错算法的表现很有辨识度:输出文件大小对得上,但内容是一堆规律的乱码,或者解压到一半直接报错。
注意:
comtype是全局设置,一旦设了会影响后面所有的clog。如果同一个包里混用了多种算法,每条记录之前都要重新设一次。
4. 实操:手撸一个模拟包体的解包脚本
4.1 先用十六进制编辑器把结构画出来
拿到陌生包体,我的第一步永远是打开十六进制编辑器,而不是写脚本。先看头部:前 4 个字节是不是可读的 ASCII,紧接着的字段像不像版本号(通常是 1 或 2 这种小数字),再往后是不是一个数量字段。
然后找规律。资源包的一个典型特征是里面会连续出现一批结构相似的数字——比如一串递增的偏移值,间隔大致等于各个文件的压缩后大小。看到这种递增序列,基本可以断定索引表的位置和每条记录的长度。
我把这一步叫作“画结构图”,实际就是拿张纸或者一个文本文件,把推测的布局写下来:偏移 0 到 3 是魔数,4 到 5 是版本,6 到 9 是文件数量,10 开始是索引,每条 14 字节加变长名字。写完再回头用十六进制验证一遍,特别是每条记录的长度对不对得上索引表的总长度。这一步做扎实,后面写脚本基本是一次过。
4.2 第一版脚本:先跑通
假设我们面对的是这样一个模拟包体:头部 4 字节魔数,2 字节版本,4 字节文件数量;每条索引记录是 4 字节绝对偏移、4 字节压缩后大小、4 字节原始大小、2 字节名字长度,然后跟变长名字;数据区紧跟在索引表之后。
第一版脚本的目标是“能列出文件名”,不追求导出正确,先确认索引读对了:
idstring "MPAK" get version short get count long for i = 0 < count get data_offset long get comp_size long get orig_size long get name_len short getdstring name name_len print "%name% | off=%data_offset% | c=%comp_size% | u=%orig_size%" next i跑一次,看输出的行数是不是等于count,文件名是不是可读的英文或者数字。如果行数对、名字乱码,八成是名字长度字段的位置算错了,或者名字本身带了编码(后面单独说)。如果行数对、名字对,但偏移数字大得离谱,回头检查字节序。
这一版跑通之后,才往脚本里加导出逻辑。分两版写的好处是:出错时你能立刻判断是“结构读错了”还是“导出环节错了”,不用在两个问题之间来回猜。
4.3 第二版:加解压、加子目录、加校验
导出逻辑加进去,同时把压缩分支补上:
idstring "MPAK" get version short get count long for i = 0 < count get data_offset long get comp_size long get orig_size long get name_len short getdstring name name_len if comp_size == orig_size log name data_offset comp_size else comtype zlib clog name data_offset orig_size comp_size endif next i几个实操要点。第一,输出路径默认是相对输出目录的,如果名字里带斜杠,会自动建子目录;有些包体的名字用的是反斜杠,需要先替换成正斜杠,否则在 Linux 上会生成一堆带奇怪字符的文件名。第二,导出前最好给输出目录清空一次,不然上一次失败的残留文件会混进来,排查时非常干扰。第三,如果包体里有几百个文件,建议先用-f过滤只导出一两个做验证,确认内容能正常打开,再全量跑。
我一般还会在脚本开头加一段版本判断,把读到的版本号打印出来。很多包体在不同版本之间结构有微调,把版本号记下来,下次遇到同类包体能省掉一半的摸索时间。
4.4 批量与命令行参数
脚本跑通之后就是批量处理。命令行里几个参数组合起来很实用:
quickbms -o -Y unpack.bms archive.pak ./out quickbms -l unpack.bms archive.pak quickbms -o -Y -f "*.png" unpack.bms archive.pak ./out_png第一条是全量导出,覆盖已有文件、所有询问自动确认。第二条只列文件不导出,用来快速看包里有什么。第三条按通配符过滤,只导出贴图,做素材整理时特别省时间。
如果要处理的归档不止一个,写个循环最省事。Windows 下用批处理,Linux 下用 shell 循环,把归档文件名传进脚本即可。注意每个归档导出到独立目录,不要全部塞进同一个输出目录,否则同名文件会互相覆盖,而且你事后根本分不清哪个文件来自哪个包。
4.5 回封(reimport)的真实限制
QuickBMS 有一个回写模式,理论上可以把改过的文件塞回原包。用法是加-r -w参数,配合原始脚本和原归档。听起来很美,但实际用起来限制不少,我把话说明白:它并不重新压缩,只是在原来的位置用新数据替换同样长度的内容。也就是说,你改的文件必须和原文件大小完全一致,否则要么截断要么失败。
这个限制决定了它只适合做小改动——换一张同尺寸贴图、改几行文本、替换一个音频文件。想往里加新文件、想改变文件大小,基本做不到。我在实际使用中把回封当“验证工具”用:解出来、改一点点、封回去、跑一遍确认能正常加载,证明我的结构理解是对的。真要大规模修改,还是得走引擎自己的打包流程。
注意:回封前一定要备份原归档。写模式直接操作原文件,出问题是不可逆的。
5. 排查速查表:解包出来不对怎么定位
5.1 症状-原因-动作速查表
排查这件事,最怕的是没有方向地乱试。我把这些年遇到的情况整理成一张表,按症状对原因,基本能覆盖八成的问题:
| 症状 | 最可能的原因 | 优先动作 |
|---|---|---|
| 脚本一开始就报魔数不匹配 | 魔数位置偏移,或包体有前置头 | 十六进制里搜特征字符串,改用goto跳转后再比对 |
| 文件数量读出来是几百万 | 字节序错,或字段类型宽度选错 | 切换endian,把 long 换成 short 试 |
| 文件列表正确但导出全是几字节 | 偏移基准理解错(相对段首 vs 文件头) | 打印第一条记录的偏移,手动在十六进制里核对 |
| 导出文件大小对但内容乱码 | 压缩算法设错,或本该解压却用了log | 换comtype逐个试,或改用clog |
| 部分文件正常、部分损坏 | 包里混用多种压缩算法 | 每条记录前重设comtype |
| 文件名乱码或截断 | 名字编码非单字节,或长度字段含义不同 | 按双字节长度重新推算,或跳过名字只看偏移 |
| 导出目录一堆奇怪文件名 | 名字里的路径分隔符方向不对 | 统一替换成正斜杠 |
这张表不是万能的,但它能帮你把“完全没头绪”变成“有两条路可以试”。
5.2 偏移错位的三种典型来源
偏移错位是解包失败里最高频的一类,我总结出三种来源,几乎每次都能对上号。
第一种是基准不同。索引里存的偏移,可能是相对文件头,也可能是相对数据区起点,还可能相对索引表结束位置。这三种在实际包体里都出现过。判断方法很简单:拿第一条记录的偏移值,去十六进制里跳过去看,如果那个位置正好是某个文件的开头(比如 PNG 头、音频头),说明基准对了;如果跳过去是乱码,往前或往后挪一段再试。
第二种是索引记录长度算错。有些包体的名字字段是定长的,不足补零,有些是变长的带长度前缀,还有些是“总长度减去固定部分”倒推出来的。长度算错会导致从第二条记录开始整体错位,表现是第一个文件能导出、后面全乱。
第三种是填充与对齐。部分包体会在每条记录后面补几个字节做对齐,或者在索引表和数据区之间插一段保留块。这种情况下,读完一条记录后需要额外跳几个字节,跳多少要靠观察相邻记录的实际间隔来推。
5.3 压缩流读不出内容的排查顺序
确认了偏移正确、数据也取到了,但解压失败,我的排查顺序是这样的。
先确认这段数据到底是不是压缩流。看开头几个字节,zlib 的流一般以固定的两字节开头,deflate 裸流没有固定头但有明显的熵特征。如果开头是明显的结构化数据,那它根本没压缩,直接log就行。
再确认算法选对。同样是 zlib 家族,带头的和不带头的处理方式不同;LZMA 也有带长度前缀和不带前缀的差别。脚本库里的现成脚本通常会把这些细节处理好,自己写的时候容易漏。
最后确认解压后的大小。clog需要你告诉它解压后的目标大小,这个值如果给错,有的算法会直接报错,有的会解出一半就停。目标大小一般就在索引里,但要注意有些包体存的是“对齐后的大小”,实际数据比这个值小,解出来尾部会多一段填充,这个属于正常现象,不用当成错误。
提示:排查压缩问题时,先拿包体里最小的那个文件做实验。小文件解压快、失败快,试错成本低。
6. 一些只有踩过才懂的经验
6.1 文件名与编码
名字乱码是新手最容易被劝退的问题。表现是文件名显示成一堆问号或者方块,但文件内容其实是好的。这种情况大概率是名字用了双字节编码,而你按单字节长度去读了。
处理方式有两种。一种是按字节读进来不做转换,直接当文件名用,只要文件系统支持就没问题;另一种是按双字节长度推算,读完再转成单字节。我一般先用第一种,能跑通就不折腾,实在需要可读的文件名,事后再写个小脚本批量重命名。
还有个小坑:某些包体的名字字段里带绝对路径,甚至是开发者本机的路径。直接拿去当输出路径,会在输出目录里生成一串莫名其妙的嵌套文件夹。写脚本时把路径部分截掉,只保留文件名,能省掉大量整理工作。
6.2 大文件与内存
处理超大归档时,不要图省事把整个文件读进内存。BMS 脚本里用log和clog都是按偏移读的,本身就是流式的,不需要预读。真正的内存风险来自“先把索引整体读成一个大字符串再切”的写法,索引一旦有几万条,字符串操作会非常慢。
另一个经验是分批跑。一个几十 G 的包,一次性全量导出可能跑几个小时,中间任何一步出错都得重来。我的做法是先按文件类型过滤,分批导出,每批跑完检查一下数量和几个样本文件,确认没问题再跑下一批。
6.3 合规边界与素材使用
最后说一件必须说清楚的事。这类工具本身是中性的,用途取决于使用者。我给自己定的规矩是:只处理自己合法拥有的、用于学习和研究的文件,解出来的素材不传播、不再分发、不用于任何商业用途。别人的资源包、付费内容、在线服务里抓下来的数据,一律不碰。
这条线看起来是限制,实际上是保护。技术探索的乐趣在于搞懂结构、写通脚本、解决问题,而不是拿到多少素材。我见过太多人一上来就问“这个包怎么拆”,却连包里是什么都不清楚,拆完也不知道拿来干什么。真想在这条路上走得远,把精力放在理解二进制结构、练习写脚本、积累排查经验上,比攒一堆素材有用得多。
写脚本这件事有个很实际的进步路径:先改别人的脚本,再写自己格式的脚本,最后做到拿到一个陌生包,能在半小时内画出结构图、写出第一版能跑的解析。到这一步,工具本身就不重要了,重要的是你已经具备了一套面对未知二进制时的思考方式。