ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

QuickBMS游戏解包实战:BMS脚本解析、回封与资源流水线

QuickBMS游戏解包实战:BMS脚本解析、回封与资源流水线

1. 为什么游戏解包这件事值得单开一篇讲

先说清楚我在做什么:把 QuickBMS 这套脚本驱动的解包方案,从“听说很神”讲到“我能自己改脚本把它跑起来”。QuickBMS 是一个完全免费的通用游戏文件提取与转换工具,它本身不认识任何一种游戏格式,靠的是一堆.bms文本脚本去描述“这个文件里哪儿是文件表、哪儿是偏移、哪儿是压缩块”。脚本一换,它就能对付两百多种以上的打包格式,Windows、Linux、macOS 上都能跑。第一次接触它的人容易把它当成“万能解包器”下载下来,结果双击打开发现要选脚本、要看命令行输出,瞬间懵掉。我给它的准确定位是:一个把“二进制格式分析”这种苦活,抽象成百来行可读脚本的执行引擎。

它解决的问题很具体。做美术参考、做本地化文本抽取、做老资源归档、做 MOD 替换素材、做自己项目的资源管线复用,几乎必然会撞上“这个文件打不开”的墙——扩展名是.pak、.dat、.bin、.assets、.bundle,十六进制打开一看全是偏移量和长度表,纯手工扒要人命。QuickBMS 的价值就在于:网络上已经有大量志愿者写好的脚本,大部分常见格式直接对号入座;对不上的,你自己照着 BMS 语法写几十行也能通。

这篇文章适合谁?三类人:一是刚入门、想搞明白“解包到底在解什么”的新手;二是手头有一堆文件要批量处理、需要命令行和批处理的熟手;三是脚本跑到一半报错、想自己动手修的进阶用户。我尽量把每一步的“为什么”讲透,参数怎么算、脚本为什么要这么写、踩过哪些坑,都摊开说。

提醒一句:所有操作都请建立在自己拥有合法授权的资源上,比如自研项目的产物、获得授权的本地化工作、个人购买的软件内资源做学习研究。工具本身中性,用途得自己把住。

2. 核心机制拆解:BMS 脚本凭什么能通吃各种格式

2.1 解包的本质就是在二进制里按图索骥

很多人把解包想得很玄,其实拆开看只有三件事:找到文件表、读出每个文件的偏移和长度、按偏移把数据切出来。绝大多数打包格式的结构都长这样:开头一段固定字节的标识(magic),接着是版本号、文件数量,然后是一张表,表里每个条目记录着“文件叫什么、在包里的第几个字节开始、有多长、有没有压缩”。剩下的才是数据区。

QuickBMS 干的事就是让你用脚本把这三步描述出来。它不是逆向工具,不做任何智能猜测(少量通用脚本确实会尝试探测,但那是脚本作者写的逻辑,不是工具本身的能力)。所以你会看到一个很有意思的现象:同一个引擎出的两款产品,包里结构稍有不同,就得两个脚本;反过来,两个完全不相关的产品,如果都用了同一种打包库,一个脚本能同时通吃。这也是脚本库动辄几百个文件的原因——它积累的是“格式指纹”的集合,不是“游戏清单”。

理解这一点非常关键,因为它决定了你的工作流:先识别格式,再找脚本,找不到就自己写或者改。别指望把文件拖进去就自动出结果,那是把工具当魔法用,走不远的。

2.2 BMS 脚本的语法骨架,其实就十几条指令

BMS 是一门极简的脚本语言,没有函数、没有对象,只有变量、跳转和读写。核心指令我列一下,看懂了这几条,剩下的都是组合:

指令作用典型用法
endian声明字节序endian little/endian big
idstring校验文件头标识idstring "PAK0",不匹配直接报错退出
get按类型读一个值get FILES long、get SIZE longlong
getdstring读定长字符串getdstring NAME 0x40
goto跳转到绝对/相对偏移goto DATA_OFF、goto -8 0 SEEK_CUR
savepos记下当前位置savepos TMP,配合 goto 回溯用
math算术运算math OFFSET += BASE
log把一段原始数据写盘log NAME OFFSET SIZE
clog解压后写盘clog NAME OFFSET ZSIZE SIZE
comtype指定压缩算法comtype zlib、comtype lz4
for / next循环遍历文件表
if / endif条件判断处理有无压缩标志的分支

看一个能跑通的最小脚本,假设某个包格式是:magicPAK0,然后文件数、数据区起始偏移,接着是表,每条 12 字节(偏移 4 字节、长度 4 字节、名字偏移 4 字节),名字以\0结尾。

# 最小可用的打包格式解析示例 endian little idstring "PAK0" get FILES long get DATA_OFF long for i = 0 < FILES get OFFSET long get SIZE long get NAMEPOS long savepos TMP goto NAMEPOS get NAME string goto TMP log NAME OFFSET SIZE next i

这段不到二十行,就把一个包拆干净了。注意savepos / goto这一对——读名字的时候必须跳到名字区,读完还得跳回来,否则下一轮循环的位置就全乱了。这是新手写脚本第一个高频错误,我在第一次写的时候也栽过,循环跑第二轮就报“offset out of range”,查了半天才发现是没回位。

2.3 三条路线:专用脚本、通用脚本、自己写

实际干活时你有三条路可走,成本和对能力的要求完全不同。

第一条是专用脚本。脚本库里针对具体格式写好的脚本,通常命名能看出对应关系,比如按扩展名或按结构特征命名。这条路成功率最高,因为作者大概率处理过各种变体,脚本里塞了一堆兼容分支。找脚本的正确姿势不是搜“某某游戏解包”,而是先看文件的十六进制头,抓出 4 到 8 个字节的特征,再拿这个特征去脚本库里对。文件名、扩展名都不靠谱,改个后缀就是另一个格式。

第二条是通用脚本。有些脚本是“格式无关”的,比如只处理最简单的“偏移+长度”结构,或者干脆就是个文件切割器——把一个大包里连续的块按固定大小切开。这类脚本的适用面广但成功率不保证,属于“先试试,不行再说”。

第三条是自己写或者改。当你手上是个自研引擎的小众格式,或者是个改过的变体,那就只能自己动手。好消息是大多数情况下你不需要从零写,拿一个结构相近的脚本改偏移量和字段顺序,比从头来快十倍。我在处理一个把文件表放在包尾部、还做了简单混淆的格式时,就是拿一个“表在头部”的现成脚本改的,关键改动只有三处:goto的基准位置、表项的字段顺序、以及名字读取前的解密运算。

一个判断经验:如果你打开文件发现前 4 个字节是常见压缩流的魔数(比如 zlib 的头),那这个包可能是“整包压缩”,得先把外层解开再谈解析内部结构。这种情况脚本里第一行就该是clog而不是log。

3. 从零跑通第一次解包:环境、参数与实操

3.1 版本选择与目录规划,别把文件摊在桌面上

工具本体是绿色版,下载解压即用,不用安装。但版本上有个坑必须提:普通版和大文件版是两个可执行文件。普通版处理小文件更快、内存占用低,但单个包超过一定体积就会在读偏移时溢出;大文件版内部用 64 位偏移,代价是慢一点、吃内存。我的做法是两个都留着,按包的体积选——先看文件大小,超过一两个 G 的直接上大文件版,省得跑到一半报个莫名其妙的错。

目录规划这件事看着不起眼,但它决定了你后面排查问题的效率。我固定的结构是这样的:

D:\unpack\ ├── bin\ # 工具本体,两个 exe 都放这 ├── scripts\ # 脚本库,按格式首字母分子目录 ├── in\ # 待处理的原始包,只读,绝不在这改 ├── out\ # 解包输出,一个包一个子目录 └── logs\ # 每次运行的输出重定向到这里

关键是in目录只读。我见过太多人直接在原包目录里跑,输出也丢那儿,跑完发现分不清哪个是原文件哪个是产物,回滚都没法回。另外out下面按包名建子目录也很重要,因为解出来的文件经常是扁平结构,几百个文件糊在一个文件夹里,后续根本没法处理。

Linux 下多数发行版的软件源里就有,一条包管理命令装完就能用,路径通常在/usr/bin/下;如果没有,下载源码编译也很快,依赖只有 zlib 一类的常见库。macOS 同理,编译完记得把可执行文件加进PATH。

3.2 命令行参数,常用的就那五六个

图形界面的用法是双击 exe 弹出窗口,依次选脚本、选输入文件、选输出目录,点一下跑。简单场景够了,但只要你需要处理第二个包,就该转命令行——因为命令行才能批处理、才能把输出重定向到日志里、才能在出错时看到完整堆栈。

常用参数我整理成表,其余的参数建议直接跑一次帮助输出,以工具自报的为准,别背网上的老版本说明:

参数含义我什么时候用它
-o覆盖已存在的输出文件,不再逐个询问批量重跑,尤其是调试脚本时反复跑
-l只列出包内文件清单,不写盘第一次接触一个包,先看结构再决定怎么解
-r回封模式(reimport)改完素材要塞回原包
-f按通配符过滤,只处理匹配的文件只想抽贴图或只想抽文本时
-d打开调试输出,打印每一步读写的位置脚本跑到一半崩了,靠它定位断点

典型命令长这样:

# 先列清单,确认脚本对得上 quickbms.exe -l scripts/pak_format.bms in/archive.pak # 确认没问题再正式解包,输出到独立目录 quickbms.exe -o scripts/pak_format.bms in/archive.pak out/archive/ # 只抽图片类文件 quickbms.exe -o -f "*.png" scripts/pak_format.bms in/archive.pak out/archive/

这里解释一下-l为什么值得单独跑一次。它不光能验证脚本能不能正确解析文件表,还能让你提前看到文件数量、命名规则、有没有嵌套目录。我有一次就是因为跳过了这步,直接解包输出到一个已经有同名文件的目录,-o一开,把之前的结果全盖了,白跑两小时。从那以后我养成习惯:新格式第一次跑,先-l,再解到空目录,最后才动正式目录。

3.3 脚本对不上怎么办:用十六进制头反查

这是实操里最高频的卡点。你把文件拖进去、选了个脚本、回车,屏幕上一行红字:标识符校验失败。这时候不要乱试脚本,试一百个也白搭,得回到二进制去看。

步骤是这样的:先用十六进制编辑器打开文件头,把前 16 个字节记下来;再往下翻,找到看起来像文件表的位置(特征是出现连续的小整数,且数值递增,像一串偏移量);然后把这两段特征跟脚本里的idstring和字段读取顺序做比对。如果idstring完全对不上,说明这不是同一个格式,换脚本;如果idstring对上了但后面读出来的文件数是个天文数字,那多半是字段顺序或者字节序的问题——把endian little改成endian big试试,这一个字的改动能救回一大半案例。

还有一种情况:idstring对得上,字段也读对了,但每个文件的偏移全都差了那么一点。这通常是包头部有个变长的附加区(比如带签名字段或者对齐填充),脚本里没考虑。解决办法是在idstring之后加一句goto,直接跳过那段长度不定的区域,落到真正的表头位置。跳多少?看十六进制里文件数出现的位置减去当前位置就知道了,不用精确计算,目测加验证两轮就能定。

顺带说个细节:有些格式的文件名区是反序存储的,读出来全是乱码字符。这时候在脚本里加一句string NAME R = NAME,把字符串反转一下就好了。这个技巧不常见,但一旦遇上,不知道的话能卡一整天。

3.4 回封:改完素材还能塞回去

-r这个模式是 QuickBMS 区别于很多提取工具的地方。它不只能拆,还能把改过的文件塞回原包,前提是脚本本身支持回封——判断方法是看脚本里有没有put系列指令,以及开头有没有if REIMPORT这类分支。

回封的原理是:以原包为模板,按文件表把每个条目对应的数据区用新文件内容覆盖,长度不变就直接写,长度变了就顺移后面所有偏移并修正表项。所以回封对“文件大小必须保持一致”的场景是最稳的,比如只改了贴图内容、换了字体文件但没改分辨率,那基本一次成功。如果你把一张 512 的图换成了 1024 的,长度变了,就得看脚本有没有实现“重建文件表”的逻辑,大部分脚本是有的,但偶尔会出问题——我遇到过回封后包能读、但某个索引文件指向错位的情况,最后是把索引文件也一起替换才解决的。

回封的标准命令是在解包命令上加-r:

# 解包(第一次) quickbms.exe scripts/xxx.bms in/archive.pak out/archive/ # 改完 out/archive/ 里的素材后,回封到新包 quickbms.exe -r -o scripts/xxx.bms in/archive.pak out/archive/ # 结果一般会生成 archive.pak.new 或直接覆盖,看脚本实现

注意回封时输入的“第二个参数”是原始包,第三个参数是改过的文件所在目录,这个顺序千万别搞反,反了会得到一个空的或者损坏的包。我第一次做的时候就是顺序反了,生成了一个 0 字节的新文件,还纳闷了半天。

4. 脚本进阶:看懂它、改它、救它

4.1 读脚本的正确顺序:先看头,再看循环,最后看特殊分支

拿到一个陌生脚本,别从头逐行读,效率太低。我的顺序是三步。

第一步看头部十行,重点看endian、idstring、以及开头几个get读的是什么字段。这一步决定了“这个脚本是不是给这个格式写的”。

第二步直接跳到for或while循环,看循环体里log或clog的参数是怎么拼出来的。这个循环就是文件表的遍历逻辑,看懂了它,你就知道偏移和长度是从哪来的。

第三步回头扫一遍if分支,这些分支通常是处理变体的——比如某个版本号之后多了一个字段、某些文件的长度是 0 需要跳过、某些文件是加密的。这些分支就是脚本作者踩过的坑的集合,读它们比读文档有用得多。

举个具体的例子。一个脚本里如果出现这样的片段:

get FLAGS long if FLAGS & 1 clog NAME OFFSET ZSIZE SIZE else log NAME OFFSET SIZE endif

那意思就是:文件表里有个标志位,最低位为 1 表示这块数据是压缩存储的,得用clog解压;否则是原样存储,直接log切出来。这种“按位判断”的写法在打包格式里极其常见,不认识位运算的话会看得很懵。

4.2 三种典型结构,认出来就能套模板

我处理过的包,结构上基本能归成三类,认出来之后改脚本就是填空题。

第一类是“表在头、数据在后”。最简单,idstring之后读数量,循环读偏移和长度,直接log。九成的格式是这个结构。

第二类是“表在尾、索引在头”。文件头只放一个指向尾部的偏移,真正的表在文件末尾。这种情况下脚本要先goto到尾部、再循环,处理完再跳回来。这里有个容易忽略的点:尾部表的条目顺序经常和数据区是反的,也就是说第一条记录对应的是最后一个文件。如果你解出来的文件内容全是错位的,八成是没处理这个反序,循环里加一句math i = FILES - 1 - i之类的重排就行。

第三类是“分块压缩”。数据区不是整块压缩,而是每 32KB 或 64KB 一个压缩块,还带一张块偏移表。这种格式用clog一把梭是不行的,得先建个数组存每块的位置和长度,再循环解压、追加到同一个输出文件。BMS 支持数组,写法不算优雅但能用:

# 分块压缩的骨架思路 for i = 0 < CHUNKS get CHUNK_OFF long get CHUNK_ZSZ long get CHUNK_SZ long putarray 0 i CHUNK_OFF putarray 1 i CHUNK_ZSZ putarray 2 i CHUNK_SZ next i append # 打开追加模式 for i = 0 < CHUNKS getarray CHUNK_OFF 0 i getarray CHUNK_ZSZ 1 i getarray CHUNK_SZ 2 i clog "" CHUNK_OFF CHUNK_ZSZ CHUNK_SZ next i

clog第一个参数传空字符串表示“不新建文件,接着上一个文件继续写”,配合append就是分块拼装。这个写法我调了挺久才摸对,文档里讲得不细,属于典型的“不实际操作一遍就学不会”的知识点。

4.3 脚本跑飞了怎么救:三个必查点

脚本出错的表现千奇百怪,但根因集中在三处,按这个顺序查效率最高。

查循环边界。最常见的是文件数读错了,比如读出来是 65536 而实际只有 200 个文件,循环就会一直跑到越界。加一句if FILES > 100000之类的保护,或者直接打印出来看一眼。

查偏移基准。所有goto和log的偏移,到底是相对文件开头还是相对数据区开头,这个基准搞错了,解出来的东西全是碎片。验证方法很简单:解出来的第一个文件,用十六进制看它的头是不是合理的(比如 PNG 应该是89 50 4E 47),不是的话基准就错了,整体加减一个常量就行。

查字节序。大端小端搞反的典型症状是:文件数读出来是个巨大数字、偏移量读出来全是负数、长度读出来比整个包还大。这时候别犹豫,endian那一行改掉重跑。

补充一个排查技巧:跑脚本的时候加-d,它会打印每一次读写的偏移和长度。你把输出重定向到文件,出错位置前最后几行就是案发现场,比盲目加打印语句快得多。

5. 常见问题与排查实录

5.1 典型报错速查表

我把这些年遇到的和被问到的错误归了个类,按出现频率排:

报错或现象大概率原因处理办法
标识符不匹配,脚本直接退出脚本和格式不对应抓文件头特征重新找脚本,别硬试
读到一半提示偏移越界偏移基准错了,或文件数字段读错加-d看断点,核对表头字段顺序
解出来的文件全是乱码数据是压缩的但用了log而不是clog判断压缩类型,补comtype和clog
文件名是乱码或乱序名字区编码问题或反序存储加string指令做反转或转码
只解出第一个文件,后面全空循环里没回位,位置指针错乱检查savepos / goto配对
处理大包时进程直接退出用了普通版,偏移溢出换大文件版可执行文件
回封后包无法读取长度变化但表没重建检查脚本是否支持 reimport,或保持文件等长
输出目录里文件互相覆盖包内是扁平结构,重名按索引重命名,或用-f分批处理

这张表覆盖了八成以上的场景。剩下两成是格式本身的特殊逻辑,只能靠读脚本和看十六进制。

5.2 那些文档里不会写的坑

坑一:输出路径带中文或空格。命令行下路径里有空格必须加引号,这个大家都知道,但中文路径的问题是另一回事——某些版本在处理非 ASCII 路径时会读到错误的位置,表现是“文件明明解出来了但内容是空的”。解决办法简单粗暴:工作目录全用英文和数字,别嫌麻烦。

坑二:-o开太早。调试脚本阶段反复重跑,-o确实省事,但如果脚本有 bug 输出了错误的文件名(比如空名字),它会生成一堆名字诡异的文件,下一轮再跑又互相覆盖,越跑越乱。我的建议是调试阶段每轮换一个输出目录,跑通了再上-o。

坑三:以为解包完就结束了。实际上解出来的东西经常还需要二次处理:贴图可能是自定义的头加上原始数据,得再剥一层;文本可能是加密或压缩的,得再解一次;模型是分片的,得拼。所以解包只是流水线的第一站,别指望一步到位。提前规划好“解包 → 格式转换 → 归档”的三段式结构,后面会省很多返工。

坑四:忽略日志。我在批处理脚本里固定把输出重定向到日志文件,每个包一份。理由很实际:跑一百个包,中间有五个失败,你不看日志根本不知道该重跑哪五个。日志里搜关键字“error”或者“failed”,比一个个点开看快多了。

5.3 批量化:把重复劳动交给脚本

单个包处理完,真正的效率提升在批处理。Windows 下用批处理文件,Linux 和 macOS 下用 shell 循环,逻辑一样:遍历输入目录、对每个文件调一次工具、输出到按包名建的子目录、日志单独存。

@echo off setlocal enabledelayedexpansion set BMS="D:\unpack\bin\quickbms_4gb_files.exe" set SCRIPT="D:\unpack\scripts\pak_format.bms" for %%f in ("D:\unpack\in\*.pak") do ( set NAME=%%~nf echo === processing !NAME! === %BMS% -o -r %SCRIPT% "%%f" "D:\unpack\out\!NAME!" > "D:\unpack\logs\!NAME!.log" 2>&1 ) echo done

几个细节值得说:enabledelayedexpansion和!NAME!的写法是必须的,普通的%NAME%在循环里取不到实时值,这是批处理的老坑;2>&1把错误输出也重定向进日志,否则报错信息只在屏幕上闪一下;-r那一位按需加,不需要回封就删掉。

Linux 下对应的是:

#!/bin/bash BMS=/opt/unpack/quickbms SCRIPT=/opt/unpack/scripts/pak_format.bms for f in /data/unpack/in/*.pak; do name=$(basename "$f" .pak) echo "=== $name ===" "$BMS" -o "$SCRIPT" "$f" "/data/unpack/out/$name" \ > "/data/unpack/logs/$name.log" 2>&1 done

跑完之后先别急着看输出,先扫一遍日志目录,grep -l -i error logs/*.log,把失败的挑出来单独处理,成功的直接进下一道工序。这个习惯能省下大量“到底是哪个包出问题了”的排查时间。

6. 把它接进自己的资源流水线

6.1 解包之后的那一段,才是价值所在

单纯把文件解出来,意义有限。真正产生价值的是解包之后接的那一段处理。举几个我自己常用的组合。

如果是贴图,解出来之后一般是需要批量转格式的:批量识别文件头判断真实格式(扩展名经常是错的),批量转成通用格式,批量重命名成有意义的名字。这套流程用普通的批量处理工具就能串起来,解包输出目录作为输入,转换输出另开一个目录。

如果是文本,解出来大概率是表格或者带标记的纯文本,需要做的是编码统一和去重。这里有个小技巧:先用工具统计一下文件的字符编码分布,别一上来就全量转 UTF-8,有些文件是混合编码,硬转会出乱码,得先分拣。

如果是音频和视频,解包往往只是剥掉了外层容器,内部还是标准格式,改个扩展名就能播。判断方法还是看文件头,看到熟悉的魔数就说明可以直接用。

这条流水线一旦跑通,你会发现解包这一步本身占的时间不到两成,剩下八成都在格式转换和归档上。所以别在解包环节过度优化,把批处理和日志做好就够用了。

6.2 归档规范:给未来的自己留条路

我踩过最痛的坑不是解不开包,是三个月后想找某个资源,发现当时解出来的目录叫out1、out2、新建文件夹,完全想不起来哪个是哪个。

现在我的归档规范是固定的:每个包一个目录,目录名是“包名_处理日期”,目录里固定放一个meta.txt,记录四件事:原始文件路径、用的哪个脚本、跑了什么命令、解出来多少文件、有没有失败项。这两行字写起来三十秒,未来能省几小时。

另外,原始包永远不删。听起来像废话,但真的有人为了省空间解完就删,结果发现脚本有 bug 解错了,只能重下一遍。硬盘便宜,时间贵。

6.3 关于脚本库的维护

脚本库会越来越大,如果不管理,找脚本的时间会超过干活的时间。我的做法是按“特征首字节 + 结构类型”分目录,而不是按来源分。因为你要找脚本的时候,手上只有文件,脑子里想的是“这个包开头是 50 4B”,不是“这个包来自哪个项目”。

另外建议自己改过的脚本单独存一份,文件名后缀加个标记,别直接覆盖原脚本。原因很实际:你改的那个版本可能只对当前这个包有效,下次遇到同格式的另一个包,原版脚本反而是对的。两个都留着,试错成本最低。

最后分享一个我自己的习惯:每次成功解出一个新格式,我都会在脚本头部加几行注释,写清楚这个格式的头部特征、表的位置、有没有压缩、踩过什么坑。三十秒的注释,下次遇到同类格式能省半小时。这套东西积累下来,就是我自己的“格式指纹库”,比任何现成的清单都好用——因为每一条都是亲手验证过的。

以上这些内容都是我一步一步踩出来的,工具本身不复杂,复杂的是每个包背后那点不一样的小心思。多拆几个,你会发现规律比想象中少,套路比想象中多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进