资讯详情

exbsa 提取工具:二进制归档目录表解析与批量提取避坑

📅 2026/10/29 2:49:02 | 华诺云谱 👁 阅读
exbsa 提取工具:二进制归档目录表解析与批量提取避坑
简介面向 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 出来对一遍。偏移长度字段说明0x004magic固定 ASCIIXBSA用来判断是不是这类容器0x044version结构版本决定目录表记录是否带额外字段0x084toc_offset目录表起始位置从文件开头算起0x0C4flagsbit0 全局压缩bit1 文件名 UTF-8bit2 名称块内嵌0x104folder_count文件夹记录条数0x144file_count文件记录条数0x184folder_name_len文件夹名称块字节数0x1C4file_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 ! bXBSA: # magic 不对就别继续了后面的偏移全是垃圾值 raise ValueError(fmagic 不匹配: {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那三位是最容易忽略的全局压缩位决定了后面要不要走 zlibUTF-8 位决定了名称块的解码方式内嵌位决定了名称块是在目录表后面还是散落在数据区。三个位任意一个判断错症状都是能跑完但文件全废。toc_offset一定要做合法性检查我习惯加一条0 toc_offset file_size - 32因为遇到过截断的包头部还在但目录表已经超出文件尾不检查就会读出越界的假记录然后在提取阶段报一个完全不相干的解压错误。2.2 两级目录表文件夹记录指向文件记录块目录表是两级结构。先是一段连续的文件夹记录每条 16 字节然后才是文件记录区。关键在于文件夹记录里的file_offset不是绝对偏移而是相对于文件记录区起点的偏移。这个起点等于toc_offset folder_count * 16。很多人第一次写解析器就栽在这里按绝对偏移去读读出来的文件记录全是乱的但程序不报错——因为偏移落在合法范围内只是指向了错误的位置。记录类型偏移长度字段说明文件夹记录0x008name_hash文件夹名哈希用于无名称时反查文件夹记录0x084file_count该文件夹下文件数文件夹记录0x0C4file_offset相对文件记录区起点的偏移文件记录0x008name_hash文件名哈希文件记录0x084size_flags低 30 位原始大小bit30 压缩bit31 保留文件记录0x0C4data_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当前目录输出根目录始终显式指定避免污染工作目录--filterdds,png,txt全部按扩展名过滤只关心某几类资源时能省掉大半 IO--flat无关闭拉平目录层级输出路径过长或需要批量比对时-j/--jobs4CPU 核数解压并发数机械盘或网络盘必须压低--skip-existing无关闭已存在则跳过中断后重跑配合增量必开--overwrite无关闭强制覆盖怀疑上次输出被污染时--encodingutf-8/cp1252自动名称块解码中文、日文、西欧字符乱码时--hash-indexmap.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网络盘压到 1SSD 上开到 4 到 8 就够了。内存这块要单独算。exbsa 默认按块处理一个块读进内存、解压、写盘、释放峰值内存大致是最大单文件解压后大小 × jobs。如果归档里有几个几百兆的大块jobs8就意味着峰值可能到几个 G。稳妥的做法是先--stat看一眼最大文件是多少# 列出体积最大的 20 个条目估算峰值内存 exbsa list ./archive.pak --stat --top 20如果最大单文件是 300MBjobs4就要预留 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, errorsreplace)绝对不要用errorsignore去解码名称。这个参数会把非法字节直接吞掉结果是文件名看起来正常但少了几个字符等到后面按名字去匹配资源的时候才发现对不上而且完全不知道是哪里丢的。errorsreplace至少会留下?这种显眼的痕迹能让你一眼看出这个包的编码不是 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 NR1 {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 NR1 {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%或者某个扩展名的数量对不上立刻就能定位到是过滤器写错了还是解析漏了。从那以后我每次批量提取都强制走一遍这一步宁可多花两分钟也不想再对着一个跑了一夜的结果猜哪里出了问题。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑