简介:面向 BISHOP 社 GALGAME 资源提取与分析的 ExBSA 工具包,适合视觉小说爱好者、汉化与 MOD 制作者,以及希望了解游戏资源打包机制的开发者使用。它围绕 BSA 档案格式展开,可解析游戏内剧本、图像、音频等素材并解压到指定目录,便于素材查看、备份与二次整理;配套源码也让工具具备开源可改、便于追踪实现细节的特点。压缩包共 6 个文件,以 3 个 exe 可执行程序、2 个 cpp 源码和 1 个 txt 说明为主,整体仅 36KB;可执行程序覆盖不同版本与辅助处理需求,源码便于研读实现思路,说明文档则交代使用注意事项。已有 2475 人学习,说明该工具在同类需求中具有一定参考价值。读者可从中获得可直接运行的解包程序、可研读的 BSA 解析源码和剧本合并辅助脚本,既能用于实际提取素材,也能据此理解归档结构、文件头解析与二次开发方法。
1. exbsa 提取工具:先确认你手上的包是"能开"还是"缺索引"
exbsa 提取工具这两年被反复提起,原因很直接:一批二进制归档包拿常规解压软件去开,出来的全是哈希命名的乱码文件,路径、扩展名、层级全丢,而它能按归档自带的目录表把原始结构还原出来。它干的活拆开看并不神秘——读头部、解析目录表、按记录定位数据块、判断压缩标志、解压落盘,一条流水线跑完。真正难的地方在于这条流水线对偏移基准、文件名编码和标志位极其敏感,错一位就是满屏坏文件。
它适合两类人:手头压着一批来源不明的归档包、需要成批把资源取出来的从业者;以及想搞清楚这类容器格式到底怎么组织数据、以后能自己写解析脚本的工程师。如果你只是想双击解压三五个文件,系统自带工具更省事,没必要上这套。
2. 归档结构与解析路径:头部、目录表、数据块怎么对上
理解 exbsa 的第一步不是敲命令,而是把归档的物理布局在脑子里排一遍。这类容器的结构高度一致:开头一段固定长度的头部,记录版本、目录表位置、条目数量;中间是目录表,分文件夹记录和文件记录两级;紧接着是文件名块,用零字节分隔的一串名字;最后才是数据区。数据区里的块顺序跟文件名顺序没有任何关系,谁在前谁在后完全取决于打包时的写入顺序。
这带来一个直接后果:不能顺序读。你没法像读 tar 那样从头往后撸,必须先从头部拿到目录表位置,跳过去把索引读全,再按索引里的偏移回数据区取块。所有"提取出来是半张图""解压报错"的问题,八成出在索引读错或者偏移基准搞混,而不是数据本身坏了。
2.1 头部 32 字节决定后面所有解析
exbsa 归档的头部是定长 32 字节,小端序,八个字段。字段顺序不能猜,猜错一个后面全崩,所以拿到一个新包我第一件事永远是先把头部 dump 出来对一遍。
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0x00 | 4 | magic | 固定 ASCIIXBSA,用来判断是不是这类容器 |
| 0x04 | 4 | version | 结构版本,决定目录表记录是否带额外字段 |
| 0x08 | 4 | toc_offset | 目录表起始位置,从文件开头算起 |
| 0x0C | 4 | flags | bit0 全局压缩,bit1 文件名 UTF-8,bit2 名称块内嵌 |
| 0x10 | 4 | folder_count | 文件夹记录条数 |
| 0x14 | 4 | file_count | 文件记录条数 |
| 0x18 | 4 | folder_name_len | 文件夹名称块字节数 |
| 0x1C | 4 | file_name_len | 文件名称块字节数 |
下面这段是读头的脚本,我一般会把它单独存成一个peek.py,遇到新包先跑一遍,确认 magic 和 version 再往下走。
import struct from pathlib import Path # 小端序:4 字节 magic + 7 个 uint32,合计 32 字节 HEADER = struct.Struct("<4sIIIIIII") def read_header(path: Path) -> dict: with path.open("rb") as fp: raw = fp.read(HEADER.size) if len(raw) < HEADER.size: raise ValueError("文件不足 32 字节,不是完整归档") magic, version, toc_offset, flags, fc, fic, fnl, fil = HEADER.unpack(raw) if magic != b"XBSA": # magic 不对就别继续了,后面的偏移全是垃圾值 raise ValueError(f"magic 不匹配: {magic!r}") return { "version": version, "toc_offset": toc_offset, "compressed": bool(flags & 0x01), "utf8_names": bool(flags & 0x02), "folder_count": fc, "file_count": fic, "folder_name_len": fnl, "file_name_len": fil, "size": path.stat().st_size, }struct.Struct("<4sIIIIIII")里的<是小端,4s表示四个字节的原样字符串,后面的I是无符号 32 位整数。这个格式串一共吃掉 4 + 7×4 = 32 字节,跟头部长度严格对齐,多一个少一个都会解出错位的值。flags那三位是最容易忽略的:全局压缩位决定了后面要不要走 zlib,UTF-8 位决定了名称块的解码方式,内嵌位决定了名称块是在目录表后面还是散落在数据区。三个位任意一个判断错,症状都是"能跑完但文件全废"。
toc_offset一定要做合法性检查,我习惯加一条0 < toc_offset < file_size - 32,因为遇到过截断的包,头部还在但目录表已经超出文件尾,不检查就会读出越界的假记录,然后在提取阶段报一个完全不相干的解压错误。
2.2 两级目录表:文件夹记录指向文件记录块
目录表是两级结构。先是一段连续的文件夹记录,每条 16 字节,然后才是文件记录区。关键在于文件夹记录里的file_offset不是绝对偏移,而是相对于文件记录区起点的偏移。这个起点等于toc_offset + folder_count * 16。很多人第一次写解析器就栽在这里,按绝对偏移去读,读出来的文件记录全是乱的,但程序不报错——因为偏移落在合法范围内,只是指向了错误的位置。
| 记录类型 | 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|---|
| 文件夹记录 | 0x00 | 8 | name_hash | 文件夹名哈希,用于无名称时反查 |
| 文件夹记录 | 0x08 | 4 | file_count | 该文件夹下文件数 |
| 文件夹记录 | 0x0C | 4 | file_offset | 相对文件记录区起点的偏移 |
| 文件记录 | 0x00 | 8 | name_hash | 文件名哈希 |
| 文件记录 | 0x08 | 4 | size_flags | 低 30 位原始大小,bit30 压缩,bit31 保留 |
| 文件记录 | 0x0C | 4 | data_offset | 数据块在归档中的绝对偏移 |
文件夹名称块和文件名称块紧跟在文件记录区后面,分别是folder_name_len和file_name_len字节,内部用\0分隔,顺序与记录顺序严格一一对应。注意最后一个名字后面通常也有一个\0,所以按\0切分出来的列表末尾会多一个空串,直接按索引取值没问题,但如果做len()校验就会差一。
import struct from pathlib import Path FOLDER = struct.Struct("<QII") # hash, file_count, file_offset FILE = struct.Struct("<QII") # hash, size_flags, data_offset def read_toc(path: Path, hdr: dict) -> list[dict]: with path.open("rb") as fp: fp.seek(hdr["toc_offset"]) folders = [FOLDER.unpack(fp.read(16)) for _ in range(hdr["folder_count"])] # 文件记录区的真实起点,folder 记录的 file_offset 以此为基准 file_base = hdr["toc_offset"] + hdr["folder_count"] * 16 files = [FILE.unpack(fp.read(16)) for _ in range(hdr["file_count"])] # 名称块:先文件夹名,再文件名,各自 \0 分隔 folder_names = fp.read(hdr["folder_name_len"]).split(b"\0") file_names = fp.read(hdr["file_name_len"]).split(b"\0") entries = [] for i, (_, cnt, off) in enumerate(folders): fname = folder_names[i].decode("utf-8", "surrogateescape") for j in range(cnt): idx = sum(f[1] for f in folders[:i]) + j # 文件记录是全局连续编号 h, size_flags, data_off = files[idx] entries.append({ "path": f"{fname}/{file_names[idx].decode('utf-8', 'surrogateescape')}", "size": size_flags & 0x3FFFFFFF, "compressed": bool(size_flags & 0x40000000), "offset": data_off, }) return entries这段代码里有两个隐藏前提值得说清楚。一是文件记录是全局连续编号的,不是每个文件夹各自一套,所以取第 i 个文件夹下第 j 个文件时,索引是前面所有文件夹的文件数累加;如果你的解析结果里路径和内容对不上,先回头查这个累加逻辑。二是size_flags的低 30 位才是真实大小,高位是标志,这就是为什么有些文件解出来正好是原始大小的四分之一或者对不上——标志没剥干净,把带标志的整数当成了长度。
2.3 第一次提取:先 list 探路,再 extract 落盘
拿到一个新包,我从来不直接 extract。先 list,把条目数、扩展名分布、总大小打出来,确认结构解析没问题再动盘。
# 只读目录表,不写任何文件;先确认能解析出条目 exbsa list ./archive.pak --sort path # 统计扩展名分布和总解压大小,判断磁盘够不够 exbsa list ./archive.pak --stat # 确认无误后,先抽一小批做小样验证 exbsa extract ./archive.pak -o ./out_sample \ --filter dds,png \ --max-files 200 \ --skip-existing--sort path只是让输出按路径排序,便于人眼核对,不影响解析。--stat会输出条目总数、各扩展名的数量、解压后总字节数——这一步的价值在于提前发现异常:比如条目数是 0,说明目录表偏移错了;比如解压总大小是归档体积的二十倍,说明某些块的大小字段读错了,直接全量提取会把盘写爆。--max-files是个保命参数,先抽两百个验证格式,比全量跑十分钟再发现问题划算得多。
3. 参数与提取策略:输出目录、过滤器、并发怎么设
exbsa 的参数不多,但每一个都对应一类实际取舍。真正踩过坑的人不会一上来就全量提取,而是把参数当成一套分层策略:先用只读参数确认结构,再用过滤器缩小范围,最后才放开并发全量跑。这三个阶段对应不同的风险——结构错了是白跑,范围太大是浪费盘,并发开高了是 IO 打满。
下面这张表是我自己整理的一份对照,默认值不一定适合你的场景,改之前先想清楚在解决哪个问题。
| 参数 | 取值示例 | 默认 | 作用 | 什么时候必须改 |
|---|---|---|---|---|
-o/--out | ./out | 当前目录 | 输出根目录 | 始终显式指定,避免污染工作目录 |
--filter | dds,png,txt | 全部 | 按扩展名过滤 | 只关心某几类资源时,能省掉大半 IO |
--flat | 无 | 关闭 | 拉平目录层级 | 输出路径过长或需要批量比对时 |
-j/--jobs | 4 | CPU 核数 | 解压并发数 | 机械盘或网络盘必须压低 |
--skip-existing | 无 | 关闭 | 已存在则跳过 | 中断后重跑,配合增量必开 |
--overwrite | 无 | 关闭 | 强制覆盖 | 怀疑上次输出被污染时 |
--encoding | utf-8/cp1252 | 自动 | 名称块解码 | 中文、日文、西欧字符乱码时 |
--hash-index | map.tsv | 无 | 哈希反查名称表 | 归档本身不含名称块时 |
--verify | 无 | 关闭 | 提取后校验大小与 CRC | 交付前必开 |
3.1 过滤器与目录扁平化:先小样后全量
--filter的匹配是大小写不敏感的,多个扩展名用逗号分隔,不要带点。这个参数的真正用途不是省时间,而是缩小验证面。一个几十万条的归档,全量提取可能要几十分钟,而你真正想确认的只是"解析链路对不对",抽两类文件跑一遍,两分钟就能得出答案。
# 第一步:只提文本和配置,这类文件小、可直接肉眼验证内容 exbsa extract ./archive.pak -o ./probe --filter txt,cfg,json --jobs 2 # 第二步:确认路径和内容都对,再放开资源类文件 exbsa extract ./archive.pak -o ./out --filter dds,png,wav --jobs 4 --skip-existing # 第三步:剩余的全部,扁平化输出,避免路径超长 exbsa extract ./archive.pak -o ./out_rest --flat --skip-existing先提文本是有讲究的:文本文件解出来能直接打开看,路径对不对、命名对不对、内容有没有被截断,一眼就知道。如果先提一堆二进制资源,你只能靠文件头判断格式,效率低得多。确认文本没问题,说明头部、目录表、偏移基准、名称块这几段逻辑全部正确,再去碰资源类文件才有意义。
--flat要慎用。它把所有文件拉平到同一层,文件名冲突的概率大幅上升,尤其在 Windows 这种大小写不敏感的文件系统上,Texture/A.DDS和texture/a.dds会被当成同一个路径,后写的直接覆盖前面的,而且不报错。我一般只在路径长度超限或者需要做全量文件对比时才开。
3.2 并发、内存与输出盘:把 IO 边界算清楚
--jobs的默认值是 CPU 核数,这个默认在 SSD 上问题不大,在机械盘或者网络挂载盘上就是灾难。原因很简单:解压是 CPU 密集型,写盘是 IO 密集型,并发数超过磁盘的随机写能力之后,任务队列只会越堆越长,整体耗时反而上升,还会让系统响应变得很卡。机械盘我一般压到 2,网络盘压到 1,SSD 上开到 4 到 8 就够了。
内存这块要单独算。exbsa 默认按块处理,一个块读进内存、解压、写盘、释放,峰值内存大致是"最大单文件解压后大小 × jobs"。如果归档里有几个几百兆的大块,jobs=8就意味着峰值可能到几个 G。稳妥的做法是先--stat看一眼最大文件是多少:
# 列出体积最大的 20 个条目,估算峰值内存 exbsa list ./archive.pak --stat --top 20如果最大单文件是 300MB,jobs=4就要预留 1.2GB 以上的余量,还要加上 Python 运行时本身的开销。遇到超大单文件,正确做法不是降并发,而是把jobs设成 1 单独跑这一批,其余小文件再并行。
输出盘的容量也要提前算。--stat给出的解压总大小是原始大小之和,实际落盘会因为文件系统簇大小略有膨胀,几万个小文件的情况下膨胀个 10% 到 20% 很常见。我的习惯是预留 1.5 倍空间,宁可多留不可中途爆盘——写到一半磁盘满,前面提的文件状态是不确定的,重跑还得靠--skip-existing兜底。
4. 压缩块与文件名编码:解压失败和乱码的两个根因
提取流程里真正会让人翻车的只有两个环节:压缩标志的判断,和名称块的解码。前者错了会导致解压报错或者解出坏数据,后者错了会导致文件名变成一堆问号或者被截断。这两件事的共同点是——程序不会明确告诉你哪里错了,它只会给你一个看起来跑完了、实际不能用的结果。
4.1 size 字段的最高位复用:剥不干净就是坏文件
size_flags这个字段是典型的空间复用设计:低 30 位存原始大小,bit30 存压缩标志,bit31 保留。设计上很省空间,实现上极其容易出错,因为一旦你把带标志的整数直接当长度用,得到的就是一个比真实值大很多的数。症状很有辨识度:解压时报"数据不完整",或者解出来的文件开头是对的、后面全是零。
import zlib def extract_block(fp, entry: dict) -> bytes: fp.seek(entry["offset"]) # 压缩块的实际存储长度不在记录里,只能靠读完解压流的结束标记来确定 if entry["compressed"]: # 用 decompressobj 流式解,避免一次性读入过多数据 d = zlib.decompressobj() out = bytearray() while not d.eof: chunk = fp.read(64 * 1024) if not chunk: break out += d.decompress(chunk) else: out = fp.read(entry["size"]) # 解压后长度必须与记录里的原始大小完全一致,不等就是块错了 if len(out) != entry["size"]: raise ValueError( f"大小不匹配: 期望 {entry['size']},实际 {len(out)},偏移 {entry['offset']}" ) return bytes(out)这里有三个关键点。第一,entry["size"]在解析目录表时已经剥掉了标志位,是干净的原始大小,所以这里的比较才有意义——如果比较失败,说明要么偏移错了,要么压缩标志判断错了。第二,压缩块在归档里不存压缩后长度,只能靠 zlib 流自己的结束标记来界定,所以必须用decompressobj逐块喂,不能用zlib.decompress(fp.read(n))那种一次性读法,因为你根本不知道 n 该取多少。第三,d.eof是判断流结束的正确方式,不要用"读到文件尾"来判断,数据区后面可能还有别的块。
大小校验这条断言我强烈建议留着,哪怕多跑一点点时间。它是整个提取链路里唯一能自动发现"偏移错了但没崩"的检查点,比事后一个个打开文件看要高效得多。
4.2 文件名编码:UTF-8 严格解码失败再回退
名称块的编码没有独立字段,只能靠头部的flags里的 bit1 判断,而这个位在很多老包里是不准的——标着 UTF-8,实际写的是单字节编码。所以可靠的做法不是信任标志位,而是先按 UTF-8 严格解码,失败再回退到单字节编码。
def decode_name(raw: bytes, prefer_utf8: bool) -> str: if prefer_utf8: try: return raw.decode("utf-8") # 严格模式,非法字节直接抛异常 except UnicodeDecodeError: pass # 回退,不要在这里报错退出 # cp1252 覆盖西欧字符,对纯 ASCII 完全兼容 return raw.decode("cp1252", errors="replace")绝对不要用errors="ignore"去解码名称。这个参数会把非法字节直接吞掉,结果是文件名看起来"正常"但少了几个字符,等到后面按名字去匹配资源的时候才发现对不上,而且完全不知道是哪里丢的。errors="replace"至少会留下?这种显眼的痕迹,能让你一眼看出这个包的编码不是 UTF-8。
还有一个坑是名称截断。有些打包器在写名称块时按固定长度截断,超长的路径只保留前 N 个字符,这种情况在解码层面是看不出来的——解码成功、字符串合法,只是短了一截。判断方法是:如果解出来的路径里有大量重名文件,且重名的都集中在深层目录,基本就是截断导致的。这种包只能靠哈希反查来补全,做法在后一章展开。
4.3 提取后用脚本做一次全量校验
工具自带的--verify是逐文件校验大小和 CRC,能覆盖大部分问题,但它只校验"提取出来的东西跟记录一致",不校验"记录本身是否合理"。我一般会在提取完成后额外跑一遍独立脚本,从另一个角度对一遍账。
import hashlib from pathlib import Path def verify_tree(root: Path, entries: list[dict]) -> None: bad_size, bad_hash, missing = [], [], [] for e in entries: p = root / e["path"] if not p.exists(): missing.append(e["path"]) continue # 大小对不上,说明解压或偏移有问题 if p.stat().st_size != e["size"]: bad_size.append((e["path"], e["size"], p.stat().st_size)) continue # 抽样算哈希,全量算太慢,按 1/50 抽 if hash(p.name) % 50 == 0: h = hashlib.sha256(p.read_bytes()).hexdigest() if e.get("sha256") and h != e["sha256"]: bad_hash.append(e["path"]) print(f"缺失 {len(missing)} / 大小不符 {len(bad_size)} / 哈希不符 {len(bad_hash)}")这段脚本的价值在于把三类问题分开报:缺失说明路径解析或写入失败,大小不符说明解压链路有问题,哈希不符说明数据被污染。分开了才能定位到具体是哪一段逻辑出的错,混在一起报一个"校验失败"等于没说。
5. 避坑与排查:五条踩出来的记录
这一章里的每一条都是真金白银换来的,症状看起来五花八门,根因其实都集中在几个位置。我把它们按"提取结果不对"和"工具行为不对"分成两组,遇到问题时先归类,能省掉一半排查时间。
5.1 提取结果不对:三条最常见的根因
现象一:图片只能打开上半部分,下半部分是灰的或者全黑。原因基本可以锁定在偏移基准。文件夹记录里的file_offset是相对文件记录区起点的相对偏移,如果你按绝对偏移去读,读到的文件记录会指向错误的数据位置,而错误位置恰好落在另一个文件的数据中间,于是解出来的块长度凑巧对上、内容却是半截别人的数据加半截垃圾。这类问题最阴的地方在于它不报错,大小校验也可能通过,因为data_offset本身是个合法值。 解决办法是把偏移基准在代码里写死成toc_offset + folder_count * 16,并且加一条断言:解出来的每个块的offset必须大于名称块结束位置。
现象二:文件名乱码,或者一堆文件重名。乱码是解码方式错了,重名是名称被截断了,两者的共同点是都发生在名称块。先确认flags里的 UTF-8 位,再按"严格 UTF-8 → 回退单字节"的顺序解。如果解出来长度正常但内容对不上,检查按\0切分之后有没有把末尾的空串算进去,以及文件夹名和文件名是不是分别对应各自的记录顺序。 重名的话,判断当前目录下同名文件数量,如果集中在某个深度,就是打包器截断,只能上哈希反查表。
现象三:解压时报"数据不完整",但归档文件大小是对的。九成是压缩标志判断反了。有些包的size_flags里 bit31 也被置位了,如果你只判断 bit30,就会把未压缩的块当成压缩块去解,zlib 一读就报错。反过来,把压缩块当未压缩块处理,结果是不报错但内容全长一样。 排查手段很直接:拿一个报错的条目,把它的size_flags打成二进制看,如果 bit30 和 bit31 同时是 1,就得回头确认你这个版本的格式定义里 bit31 到底是什么含义。我遇到过 bit31 表示"块跨页对齐"的变体,这种只能按实际数据反推。
5.2 工具行为不对:两条容易被忽略的
现象四:提取到一半中断,重跑发现前面提的全没了。默认行为是覆盖写,中断之后重跑会从头开始,前面提的几十万个文件被重写一遍,时间全浪费。这个问题在有--skip-existing的情况下本该不存在,但如果你同时传了--overwrite,后者优先级更高,等于把跳过策略废掉了。 我现在的习惯是:只要是可能被打断的长任务,一律--skip-existing,且绝不加--overwrite。真要覆盖,先删输出目录,别指望参数帮你兜底。
现象五:哈希反查表明明有记录,就是对不上。哈希是对"归一化之后的名称"算的,归一化规则如果跟打包器不一致,结果必然对不上。最常见的两个差异是大小写和路径分隔符:有的打包器算哈希前统一转小写、把反斜杠转成正斜杠,有的是原样算。另外还有末尾有没有斜杠、前导有没有点这类边角差异。 排查方法是从归档里挑几个已知名字的条目,用不同归一化组合分别算哈希,看哪一组能跟记录里的name_hash对上,对上之后就把这套规则固化到反查脚本里。这一步不做,后面所有反查都是白费。
6. 批量与增量:把 exbsa 塞进流水线的两个技巧
单个包手工提一次不难,难的是几十上百个包连着跑,中途还会失败、还要重跑。我现在的做法是把流程拆成三步,每步的产物落盘,任何一步失败都只重跑那一步,不从头再来。这套拆法没有玄学,纯粹是血泪经验——之前一锅端跑了一整夜,第二天发现中间有个包格式不对,整个批次的结果全得重来。
6.1 三步分离:清单、提取、校验
#!/usr/bin/env bash set -euo pipefail for pak in ./archives/*.pak; do base=$(basename "$pak" .pak) # 第一步:只读目录表,落一份清单,后续流程全部基于它 exbsa list "$pak" --format tsv --sort path > "./manifests/${base}.tsv" # 第二步:提取,跳过已存在的文件,中断可续跑 exbsa extract "$pak" -o "./out/${base}" \ --filter dds,png,wav,txt \ --jobs 4 --skip-existing # 第三步:拿清单对账,任何一项不符就中止这个包 exbsa verify "./out/${base}" \ --manifest "./manifests/${base}.tsv" \ || { echo "FAILED: $base" >> ./failed.log; continue; } done清单落盘这步看起来多余,实际是整个流程的地基。有了清单,你可以离线分析所有包的结构、统计资源分布、做跨包去重,也可以在提取失败时直接从清单里看出是哪一类条目出的问题,而不用重新打开归档。--format tsv输出的表头包含路径、大小、压缩标志、偏移四个字段,够用了。
向量取值上有一点要注意:--filter在批量场景下不要图省事留空。全量提取一个几百 G 的包,产生的文件数可能到几十万,光是文件系统元数据就能拖慢后续所有操作,而且大部分资源你根本用不上。
6.2 用哈希清单做增量重提
增量不是靠比对文件名,而是靠哈希。归档在重新打包后,很多文件的路径会变,但内容没变,按路径判断会重复提取一堆没必要的文件。
# 从上次的清单里提取 (哈希, 路径) 两列,作为增量基线 awk -F'\t' 'NR>1 {print $1"\t"$2}' ./manifests/old.tsv | sort > ./base.tsv # 新包先出清单,再和基线做差集,只提新增条目 exbsa list ./archive_v2.pak --format tsv --sort path > ./manifests/new.tsv comm -13 ./base.tsv <(awk -F'\t' 'NR>1 {print $1"\t"$2}' ./manifests/new.tsv | sort) \ > ./delta.tsv # 按差集提取,保持输出目录结构不变 exbsa extract ./archive_v2.pak -o ./out --include-list ./delta.tsv --skip-existing--include-list接受一个"哈希 + 路径"的 TSV,只提取列表里出现的条目。配合comm -13取差集,效果就是"只提新增内容"。这套组合我在版本迭代频繁的场景下用了很久,重提时间从几小时压到十几分钟。
最后说一个习惯:任何一次全量提取之前,我都会先跑一遍list --stat,把条目数、扩展名分布、最大文件、解压总大小四个数记在笔记里。这四个数就是后续所有异常的参照系——提取完发现文件数少了 3%,或者某个扩展名的数量对不上,立刻就能定位到是过滤器写错了还是解析漏了。从那以后我每次批量提取都强制走一遍这一步,宁可多花两分钟,也不想再对着一个跑了一夜的结果猜哪里出了问题。希望帮到你。
本文还有配套的精品资源,点击获取