Wahkah/BG3SE

GitHub: Wahkah/BG3SE

一个用Python从零实现的《博德之门3》存档解析库与编辑器,完整逆向了LSPK/LSF/LSMF文件格式并附带详尽的格式文档。

Stars: 0 | Forks: 0

# BG3SE — 一个《博德之门 3》存档库与编辑器 这里的一切 —— LSPK 包格式、LSF 资源格式,以及无文档的 LSMF entity-component 容器 —— 都是用 Python 从头实现的。它不依赖于 LSLib、Divine.exe 或任何其他 Larian 工具。 ## 目录 - [可用的功能](#what-works) - [写入难题](#the-write-problem) — 尝试了什么,什么失败了,以及为什么 - [安装与运行](#install-and-run) - [格式文档](#format-documentation) - [编辑任何内容的潜在影响](#implications-of-editing-anything) - [如何在此基础上构建](#how-to-build-on-this) - [测试](#testing) ## 可用的功能 以下所有内容均通过真实的存档和已安装的游戏数据进行验证,而非推断得来。 | 功能 | 状态 | |---|---| | 解析 `.lsv` 存档 (LSPK v18 容器) | ✅ | | 解析 `.lsf` 资源文件 (v6 和 v7) | ✅ | | 解析 `NewAge` LSMF entity-component blob | ✅ | | 读取属性值、职业、子职业以及各职业等级 | ✅ | | 读取每次升级的专长、经验、种族 | ✅ | | 通过游戏根模板(约 25,500 条目)命名物品 | ✅ | | 浏览/搜索任何 `.lsf` 中的每个节点和属性 | ✅ | | 浏览约 350 种 ECS 组件类型及其原始元素 | ✅ | | 跨平台存档发现 (Windows / macOS / Linux+Proton) | ✅ | | 桌面应用 (原生 webview) 和浏览器模式 | ✅ | | 写入游戏可加载的存档 **包含角色编辑** | ❌ | ### 验证 格式层经过验证比对了**跨越三个引擎版本的 111 个存档**,且不包含任何特定版本的代码: | 来源 | 存档数 | 游戏版本 | 引擎 | |---|---|---|---| | 本地通关 | 32 | `4.1.1.4854838` | `4.6.300` (Patch 6) | | 第三方 (Nexus) | 1 | `4.1.1.5009956` | `4.6.300` | | 第三方 (Nexus) | 78 | `4.1.1.6995620` | `4.8.0.500` (Patch 8) | | 本地创建 | 1 | `4.1.1.7209685` | `4.8.0.700` (Patch 8) | 对于其中的每一个:所有 LSF 编解码器重新编码后都与其声明的字节长度完全一致,每个资源树都通过了 解析 → 写入 → 解析 的测试,并且每个 ECS blob 都能**逐字节一致**地重新序列化。组件数量因存档而异(129–362),并且是从头部读取的,而不是假设的。 解码是经过交叉验证而非简单断言的: - 职业等级与存档摘要相符,包括摘要仅以总数形式报告的多职业分割情况 —— 例如一个被简单列为“9 级”的角色,其实际解析结果为 `Wizard (NecromancySchool) 7 / Rogue 2`。 - 种族结果符合背景设定:卡尔赫 `Tiefling_Zariel`,影心 `HalfElf_High`,盖尔 `Human`。 - 专长的落点完全符合 5e 规则在 4/8/12 级的要求。 - 属性值:12 个角色中有 11 个以其职业的主属性领先。 唯一的例外是一个 STR 18 / DEX 16 的武僧 —— 这是正确的,因为该角色的 4 级专长被另一个解码器独立解码为 `TavernBrawler`,这是一种以力量为核心属性的流派。两个独立的解码器得出一致结果,这比任何单一解码器提供的证据都要可靠。 ## 写入难题 这是该项目最真实的核心部分。**以下所有内容均是针对真实的《博德之门 3》安装进行测量得出的**,而非凭空推测。 ### 简短版 两个独立的机制阻止了编辑: 1. **任何重新打包都会触发“篡改或损坏”警告** —— 即使是仅更改了几字节 JSON 的打包。 2. **对 entity-component arena 的编辑还会导致存档根本无法加载** —— 游戏会抛出错误并返回主菜单。 尽管针对这两个候选字段尝试了大约 200 种算法/输入组合,但两个校验和都未能被成功复现。 ### 产生干净数据的测试序列 早期的测试受到了严重的干扰,有必要记录下来,以免重蹈覆辙。本地存档是 Patch 6 版本的,却被 Patch 8 的游戏加载,并且**未经修改的 Patch 6 存档本身就会发出警告**。早期的每一个结果实际上都是在测量版本差距,而不是编辑器的影响。在建立基准线之前,大约十几次游戏加载产生的数据都是无法解释的。 干净的序列使用了**由当前游戏版本原生创建**的存档,并**原位**编辑(不重命名,不移动位置 —— 这两者都会独立改变结果),每次测试**仅改变一个变量**: | 测试 | 警告 | 加载 | |---|---|---| | 未经修改的原生 Patch 8 存档 | **无** | 是 ← 基准线 | | 仅修改存档名称 (`SaveInfo.json`;`Globals.lsf` 逐字节一致) | 是 | **是** | | 修改一个属性值 (ECS arena) | 是 | **否** — 错误,返回主菜单 | ### 每个结果排除了什么或证明了什么 **容器和 LSF 写入器是正确的。** 无操作重打包 —— 这段代码在不改变任何值的情况下解析并重写存档 —— 能够被加载并进行游戏。写入的文件与原始文件在字节上是不同的(不同的字符串表引用、不同的属性排列顺序、不同的填充),但游戏接受了它。因此,重写格式本身不是问题所在。 **非 ECS 内容编辑被接受,但会触发警告。** 仅修改 `SaveInfo.json` 并原样输出 `Globals.lsf` 会产生一个可以加载并进行游戏的存档。 **ECS arena 的编辑被彻底拒绝。** 在 entity-component 数据中更改单个整数 —— 比如一个属性值 —— 就会使存档无法加载。这在一个没有版本差异干扰的原生 Patch 8 存档上得到了证实,采用原位编辑,没有改变其他任何内容。 ### 在此过程中确立的其他事实 | 行为 | 细节 | |---|---| | 存档文件夹命名 | 加载菜单显示的是**文件夹**名称(`__` 之后的部分),而不是 `SaveInfo.json` 中的 `Save Name`。 | | 文件名必须匹配 | 如果 `.lsv` 文件名与文件夹的 `__` 后缀不匹配,存档在加载时会**卡在 0%**。 | | 移动存档 | 一个逐字节一致的副本放在不同名称的文件夹中可以加载,但会发出警告。 | | 迁移的存档会发出警告 | Patch 6 的存档在 Patch 8 中加载时,即使未经修改也会发出警告 —— 这并不是损坏。 | ### 两个校验和 **候选 1 — LSPK 头部 MD5**(偏移量 `0x16`,16 字节)。在任何重新打包后都会失效,这符合它触发警告的特征。未能复现。尝试过:MD5、BLAKE2b/2s-128、SHA-1/SHA-256 截断、xxHash128、MurmurHash3-128 —— 范围涵盖整个文件、将该字段置零后的文件、单独的头部、payload 区域、文件列表块、解压后的条目表、按文件列表顺序和按磁盘顺序连接的 payload,以及连接后重新散列的按文件摘要。**约 120 种组合,没有匹配。** **候选 2 — LSMF 头部 `uint64`**(偏移量 `0x08`)。它位于组件 arena 之上,并在打补丁时被原样保留,这符合它是 ECS 校验的特征。将其置零会将失败模式从“无法加载”变为严重的**错误 223**,因此它被读取并验证,而不是被忽略。尝试过:xxHash64、XXH3-64、MD5/SHA-1/BLAKE2b 截断、CRC32 对、MurmurHash3 —— 范围涵盖 arena、section B、body、整个 blob,以及将该字段置零的 blob。**约 80 种组合,没有匹配。** 将 LSPK MD5 置零并不能抑制警告;将 LSMF 散列置零会使情况变得更糟。这两个字段都受到了检查,而且通过对明显字节范围进行简单的散列都无法满足这两个字段。 ## 安装与运行 需要 Python 3.10+。 ``` pip install -e . ``` ``` bgse gui # native desktop window bgse web # same UI in a browser, on 127.0.0.1 bgse list # find savegames bgse info # party summary bgse build # decoded build: abilities, classes, feats bgse items # every item, named bgse verify # check every container round-trips ``` 在 Linux 上还需要 GTK/WebKit 绑定: ``` sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-webkit2-4.1 ``` ### 预构建的 Windows 可执行文件 打包的 Windows 构建版本附在 [Releases](../../releases) 页面上 —— 无需 Python。解压并运行 `BG3SaveEditor.exe` 即可。 要自行构建: ``` pyinstaller packaging/bgse.spec --noconfirm ``` 在 Windows 上已验证:`dist/BG3SaveEditor/` 为 28.4 MB(一个 5.9 MB 的 exe 加上其 runtime),捆绑 UI 启动。`.github/workflows/build.yml` 中的 macOS 和 Linux 目标虽然已写好,但尚未运行过。 ## 格式文档 通过阅读真实的存档并验证每一个假设与数据的吻合程度推导而来。即使脱离编辑器也具有独立的价值。 ### `.lsv` — LSPK package,版本 18 40 字节的头部,然后是 payload,接着是包含 272 字节条目的 LZ4 块压缩文件列表。在存档中 payload 使用 **zstd** 压缩。 ``` 0x00 char[4] 'LSPK' 0x14 uint8 flags 0x04 uint32 version (18) 0x15 uint8 priority 0x08 uint64 fileListOffset 0x16 byte[16] md5 <- unidentified, see above 0x10 uint32 fileListSize 0x26 uint16 numParts 0x28 payloads begin, 8-byte aligned ``` 存档包含 `meta.lsf`、`Globals.lsf`、`StorySave.bin`、`SaveInfo.json`、一个 `.webp` 截图,以及每个缓存关卡对应的一个 `.lsf`。 ### `.lsf` — 资源文件,版本 6 和 7 64 字节的头部,然后是五个部分。有三个关键细节: 1. **`keys` 部分在头部中声明为第二位,但在磁盘上却是最后写入的。** 这只能在具有非空 keys 部分的文件(v6 关卡缓存)中观察到;如果弄错了,会导致后续的每个部分都无法解压。 2. **帧格式混合使用。** 字符串表是原始的 LZ4 *block*;`nodes`、`attributes` 和 `values` 是 LZ4 *frame*(magic `04 22 4d 18`)。需要按部分检测,而不能一概而论。 3. **`extended` 字段仅在等于 1 时才选择长节点布局。** 存档使用 `0`;游戏的根模板使用 `2` 以及 12 字节的节点。测试“非零”条件会悄无声息地误读它们。 在紧凑布局下,节点和属性各为 12 字节,属性带有其所属节点的标签,每个值直接紧随前一个值。**GUID 以小端序 16 位组存储**,而不是 .NET `Guid` 的字节顺序。`3ed74f06-3c60-42dc-83f6-f034cb47c679` 存储为 `06 4f d7 3e | 60 3c | dc 42 | f6 83 | 34 f0 47 cb 79 c6`。如果按照 .NET 的方式解码,最后两组就会被弄乱 —— 这一点是通过将解码后的值与 Osiris 数据中游戏自带的同伴 GUID 拼写进行匹配而得到证实的。 ### `NewAge` — LSMF entity-component 容器 角色数据**不在** LSF 树中。它位于 `NewAge` 区域的一个 `SCRATCHBUFFER` 属性中: ``` 0x00 char[4] 'LSMF' 0x20 uint32 name-blob length 0x04 uint8 major, minor 0x24 uint16 component type count 0x06 uint16 flags 0x26 uint16 unknown (always 32) 0x08 uint64 hash <- unidentified, gates ECS edits 0x10 uint64 section A size -> component arena 0x18 uint64 section B size -> [name blob][type records] ``` 每个 48 字节的类型记录: ``` 0x00 uint64 name offset 0x18 uint32 element size 0x08 uint32 name length 0x1C uint32 component version 0x0C uint32 entity count 0x20 uint64 element count 0x10 uint64 type hash 0x28 uint64 data offset into section A ``` 两项独立的检查证实了该布局:版本字段与约 350 个类型名称中的每一个里的 `.vN.` 相匹配,并且每个数组的结尾都正好落在下一个数组的开头。没有任何压缩 —— arena 看起来具有高熵是因为它是密集的二进制数据。 #### 堆与实体引用 固定长度的数组并没有填满 section A。紧随其后的是用于可变长度数据的**堆**。元素以一对绝对的 `(begin, end)` arena 偏移量(即序列化向量)的形式引用堆,连续的元素会链接起来,因此 `element[i].end == element[i+1].begin`。 堆的 payload 可能包含 **entity reference**,存储为指向 `core.v0.EntityId` 数组的字节偏量。`core.v0.EntityId` 是**引用数组,而非注册表**:同一个实体出现在多个槽位中(一个存档有 9,374 个槽位,包含 1,957 个不同的 GUID,其中一个重复了 99 次)。槽位 → GUID 是精确的;而 GUID → 槽位是一对多的关系。 #### 已确认的组件布局 ``` game.stats.v3.StatsComponent 36-byte elements 0x08 int32[6] ability scores, in order STR DEX CON INT WIS CHA game.stats.v0.ClassesComponent 16-byte elements, 40-byte heap payload 0x00 guid class 0x10 guid subclass 0x20 uint32 level in that class 0x24 uint32 unknown (0x299 throughout) game.character_creation.v3.LevelUpComponentData 96-byte elements, one per level 0x00 guid class chosen at this level 0x10 guid subclass (only on the level it is picked) 0x20 guid feat (only on levels that grant one) game.experience.v0.ExperienceComponent 12-byte elements 0x00 int32 current-level XP 0x04 int32 total XP ``` `game.character_creation.v3.LevelUpComponent` 为每个角色保存了一个指向 `LevelUpComponentData` 的堆指针列表 —— 这正是将连续的升级过程与特定角色绑定在一起的关键。 **`StatsComponent`、`ClassesComponent` 和 `RaceComponent` 是并行数组** —— 每个角色对应一个元素且顺序相同 —— 因此通过其中任何一个组件匹配到的行,都可以在这三个组件中读取对应数据。这使得通过职业匹配到的角色也能同时获取其属性和种族,而无需依赖通用实体到名称的映射。 #### 这些布局是如何被发现的 大多数布局是通过**扫描每个组件的字节以寻找已从游戏数据中获知的 GUID** 发现的,这能同时识别出组件和字段偏移量 —— 这比靠猜测结构体快得多。当数据是数值而不是 GUID(如属性值)时,同样的思路也适用于值的*形状*:即连续的六个合理数值,并与外部预期进行交叉核对。 ### 游戏数据 `.pak` 压缩包采用延迟加载 —— 仅读取头部和文件列表 —— 因此索引 13 GB 大小的 `Gustav.pak` 只需约 0.25 秒。职业、种族、专长、背景、神祇和起源定义从 `.lsx` 文件中解析(约 320 个条目),而物品根模板从 `Public/*/RootTemplates/` 下合并的 `.lsf` 文件中解析(约 25,500 个条目,耗时约 1.5 秒)。这两者都会被缓存到磁盘。如果没有安装游戏,编辑器仍然可以工作;只是 UUID 会保持原始状态。 ### `StorySave.bin` Osiris 故事数据库 —— 包含任务标志、对话状态以及约 7,700 个数据库。其字符串通过 **XOR 0xAD** 进行了混淆。它保存的是叙事状态,而不是角色的构建数据。目前在编辑器中处于只读且未被使用的状态。 ## 编辑任何内容的潜在影响 在将写入路径用于你关心的任何内容之前,请务必阅读此部分。 - **编辑 entity-component 数据会产生《博德之门 3》无法加载的存档。** 属性、职业、子职业、职业等级、专长和经验都存储在这里。目前没有任何已知的解决办法。 - **任何写入操作都会触发篡改警告**,包括那些不更改任何有意义内容的写入。警告绝非摆设 —— 它是游戏在告诉你它不信任该文件。 - **出现警告并不总是意味着文件已损坏**,而没有警告也不意味着它就没有问题。迁移后的 Patch 6 存档虽然完全正常,但也会发出警告;而经过 ECS 编辑的存档不仅会发出警告 *而且* 无法加载。这两者互不相关。 - **非 ECS 编辑可以加载,但缺乏深度验证。** 修改 `SaveInfo.json` 或 LSF 树属性会产生可以加载并进行游戏的存档。但还没有人对此进行过长时间的游玩测试。它可能仍然存在不易察觉的错误。 - **在可能的情况下,写入操作是原位且在字节层面精准的。** ECS arena 采用原位打补丁,因此没有任何偏移量发生变动。增加任何内容 —— 比如生成物品、增加职业 —— 都需要重建 arena,目前尚未实现。 - **备份是自动进行的。** 每次写入都会首先将原始文件复制到应用数据目录下的一个带有时间戳的文件中,并且写入操作是通过临时文件和原子替换完成的。这经过了反复测试;每次恢复都验证为逐字节一致。 ## 如何在此基础上构建 该库可按原样用于任何只读目的。具体的开放性问题,按其能解锁的最大价值排序如下: ### 1. LSMF 头部散列(偏移量 `0x08`) 破解这个是关键所在 —— 它能让角色编辑功能得以实现。从存档文件中进行黑盒猜测已经失败(约 80 种组合)。现实的途径是在游戏二进制文件中找到散列函数。有用的事实包括:将该字段置零会产生错误 223 而不是静默通过,因此它是被读取的;该字段为 8 字节;它紧接在一个 4 字节的 magic、两个版本字节和一个 `uint16` 之后,这正是结构体写入例程自然放置内容摘要的位置。 ### 2. LSPK 头部 MD5(偏移量 `0x16`) 破解这个哈希可以消除所有写入操作中的篡改警告,包括那些已经有效的非 ECS 写入。约 120 种组合均告失败。同样的建议:在二进制文件中寻找写入例程,而不是盲目猜测。 ### 3. 金币 金币*实体*已被识别出来 —— `LOAT_Gold_A`(`1c3c9c74-34a1-4685-989e-410dc080be6f`,属性 `OBJ_GoldPile`)—— 并且它们的 GUID 能解析到实体数组中。但堆叠数量尚未找到。最好的线索是 `game.inventory.v0.ContainerSlotData`(16 字节元素),其实体引用位于偏移量 0 处,并且**恰好有 30 行引用了一个存档中的 30 个金币实体**。偏移量 8 看起来像数量,但其实不是:对于一个炼金 pouch 它读数为 3,170,对于一个卷轴堆读数为 3,179,而且它也不是一个单调计数器。已经排除的情况包括:没有任何组件在同一个通关过程的两个存档中保持有处于货币范围内的整数稳定值,而且无论是 `StackMemberComponent` 还是所有者组件都不带有相邻的数量字段。 ### 4. 专长、外观、库存命名 专长已被解码并可在文件中编辑。外观(`game.character_creation.v3.AppearanceComponent`,112 字节元素)是一堆指向视觉和材质资源的 GUID 引用 —— 已解码,但如果缺少来自 pak 文件的视觉资源目录,其中没有任何内容能映射回角色创建器的选择。物品根模板可以解析出名称;但数量则不能。 ### 开始阅读代码的起点 ``` src/bgse/formats/lspk.py LSPK containers (.lsv and .pak), lazy pak reading src/bgse/formats/lsf.py LSF resource files, all ~34 attribute types src/bgse/formats/lsmf.py the ECS container, heap, entity references src/bgse/formats/verify.py self-checks: codec round-trips, tree comparison src/bgse/gamedata.py .lsx definitions and root templates from the paks src/bgse/model.py the domain layer: party, abilities, classes, feats src/bgse/api.py the UI bridge src/bgse/ui/ the interface (plain HTML/CSS/JS) ``` ## 测试 ``` pytest -q ``` 21 个测试。容器和格式测试使用合成数据,可以在任何地方运行;需要真实存档的测试在未安装任何存档时会自动跳过。其中几个是交叉验证而非单元测试 —— 解码出的职业必须与存档摘要相符,解码出的专长必须能解析到真实的定义,属性值必须在合理范围内且不能是常量块。 `bgse verify ` 会检查存档中的每个容器是否都能完整往返读写(round-trip),如果未来的补丁改变了格式,这应该是你首先要运行的命令。 ## 许可证 MIT。 ## 致谢 此处的格式知识是独立从存档数据中推导出来的。Norbyte 的 LSLib 是 Larian 格式的成熟参考工具包,对于本项目未涵盖的任何内容,都值得去参考它。
标签:Python, 云资产清单, 存档编辑器, 文件解析, 无后门, 桌面应用, 游戏, 漏洞挖掘, 逆向工具, 逆向工程