《大图书馆的牧羊人》NS / PSV 双平台汉化实战:基于单 patch.cpk 与可执行文件注入的封包方案

#switch#汉化

在针对主机平台(PS Vita 与 Nintendo Switch)的《大图书馆的牧羊人 -Library Party-》进行汉化时,最繁琐的一环是资源包(CRI CPK)的反复打包与分发。

本作原版的资源封包体量较大:Switch 版仅 bg.cpk(包含CG、立绘和各种贴图)就高达 6.6 GB;PS Vita 版如果全量替换汉化涉及的资源包,每次给测试机部署 rePatch 也要传 1.3 GB 左右。如果每次修改几张图片就要重传数 GB 的全量包,开发迭代和玩家安装都很不友好。

我们从 PS Vita 官方 1.01 版本更新的代码逻辑中得到灵感,实现了一套单 patch.cpk 的重定向注入方案:

  • 原版所有的 CPK 镜像完全不动;
  • CG 和 UI 图片汉化资产,打成一个数十兆至数百兆不等的未压缩 patch.cpk;
  • 通过修改游戏的可执行文件(NS 的 main 与 PSV 的 eboot.bin),在底层拦截 CRI 引擎的文件查找调用,优先检索 patch.cpk,找不到时再原样回退到原版 CPK。

本文记录该方案的机制设计,以及在两台实机与模拟器上联调、踩坑与修复的完整过程。


一、方案核心机制与 ID 空间映射

本作的引擎在运行期对资源的定位,底层最终全部转换为数字 ID(无论是图片还是语音)。底层查询函数不会保留或传递 bg/、vo/ 这类虚拟目录路径。

1. 查找与绑定的劫持逻辑

要实现“无感补丁挂载”,只需拦截两个核心节点:

  1. 绑定钩子(Bind Hook): 在引擎初始化、绑定第一个包 sys.cpk 的调用点,顺手调用一次 CRI 的 create_binder 与绑定接口,挂载我们的 romfs/patch.cpk(或 PSV 下的 ux0:app/PCSG00505/data/patch.cpk),记录返回的 patch_binder。
    • 如果玩家没有放 patch.cpk,绑定失败后直接置状态为 Bypass,引擎后续完全按原版逻辑运行,保证原版兼容性。
  2. 查询钩子(Lookup Hook): 在底层查找函数入口拦截。如果 patch_binder 有效且查询的是有效 ID,钩子会将 ID 加上对应偏移,改用 patch_binder 重查一遍。命中则直接返回补丁数据;未命中则无损还原寄存器与参数,走原版流程。

2. ID 空间冲突与槽位基址划分

不同 CPK 内部的原始 ID 存在大量重复(例如 ID 1874 同时存在于 bg.cpk 和 bgs.cpk)。由于补丁资源都合并到了同一个 patch.cpk 中,必须做命名空间隔离。

逆向表明,引擎初始化时会将 7~8 个 CPK 的 binder 句柄按顺序写入一张全局槽位表(sys / vo / se / bgm / face / bgs / bg 等)。查找函数被调用时,入参寄存器传入的 binder 正好就是对应槽位的当前值。

因此,钩子只需比对 binder == slot[i] 就能判定当前查询属于哪一个 CPK。我们利用 patch.cpk 中 ITOC 索引表的 16 位整数(0~65535)空间,按 CPK 划分基址:

patch_id=base(cpk)+orig_id\text{patch\_id} = \text{base}(cpk) + \text{orig\_id}

各 CPK 的基址按字典序上取整划分,例如在 NS 版上:

  • bg: 0x0000(上限 7929)
  • bgm: 0x1F00(上限 59)
  • bgs: 0x2000(上限 7929)
  • face: 0x3F00(上限 3093)
  • se: 0x4C00(上限 793)
  • sys: 0x5000(上限 10)
  • vo: 0x5100(覆盖至 0xAEFF)

查询钩子一旦确认了 binder 属于哪个槽位,加上对应的基址后再去查 patch.cpk,彻底解决了 ID 重叠问题。


二、patch.cpk 的底层结构逆向与生成实现

要让引擎无缝读取补丁包,必须先摸清 CRI CPK 封包的底层构造。通过逆向分析官方原版的几个 CPK(尤其是体量最小的 sys.cpk)、可执行文件中、以及第三方 CRI CPK 解封包工具的解析代码,我们可以确认以下格式细节。

1. Header 与 ITOC 结构

CPK 文件头为 0x00 起的 "CPK " 标记与长度声明,其后跟随 Header Packet;0x7FA 处写入固定字符串 "(c)CRI",0x800 起为 "ITOC" 标记,0x810 起为 ITOC Packet。

Header 和 ITOC 本质都是 CRI 标准的 @UTF 表(大端存储):

  • 逆向 eboot.elf 发现,引擎中包含 DataL、DataH、ContentOffset、ContentSize、ItocOffset、ItocSize、Files 等字段名,但不包含子表的列名。
  • 这说明引擎对 Header 和 ITOC 外层是按名字检索列,而对 ITOC 子表则是按固定位置偏移读取。
  • 生成策略:直接复制原版 sys.cpk 的 Header Packet 整段作为模板,其余字段保留原样,只在打包时修改 7 个核心数值:
    • ContentOffset:数据段起始偏移(按 0x1000 向上对齐)
    • ContentSize:所有数据块对齐后占用的总大小(∑align2048(FileSize)\sum \text{align2048}(\text{FileSize}))
    • ItocOffset:固定为 0x800
    • ItocSize:生成的 ITOC 结构体实际大小
    • EnabledPackedSize:所有条目的原始解压大小之和(∑ExtractSize\sum \text{ExtractSize})
    • EnabledDataSize:所有条目的实际存储大小之和(∑FileSize\sum \text{FileSize})
    • Files:补丁收录的总条目数

2. ITOC 子表的分流规则(DataL vs DataH)

ITOC 的外层定义了 4 个列:FilesL、FilesH、DataL、DataH。其中 DataL 和 DataH 分别存放两个子表,子表的列结构固定为 ID / FileSize / ExtractSize。

通过对原版 bg.cpk(7950 条)、bgs.cpk(7950 条)、sys.cpk(16 条)全量验证,确认了其严格的分流边界:

  • DataL:当文件的 FileSize 与 ExtractSize 均小于 65536 字节时存入;
  • DataH:任一尺寸 ≥65536\ge 65536 字节时存入。 打包脚本在构造 ITOC 时严格按此条件分流写入对应的子表中。

3. 数据原样存储与物理对齐

  • 压缩判定:引擎解压并非看某个标志位,而是通过扫描数据首部是否有 CRILAYLA 标记。在原版 sys.cpk 中,所有条目均为未压缩状态(FileSize == ExtractSize,且无 CRILAYLA),游戏能正常读取。
  • 因此在 patch.cpk 中,汉化资源可以直接原样写入,令 FileSize = ExtractSize = len(data),省去了复杂的 CRI 专有压缩开销。
  • 物理布局:所有补丁条目按计算后的 patch_id 严格升序排序(DataL 和 DataH 对应的数据混合交叉排列),从 ContentOffset 起逐条按 2048 字节边界向上对齐放置,末条末尾不补齐。

4. 生成器实现

基于上述规则,我们可以用 Python 编写通用的生成器:

  1. 读取配置(NS 或 PSV 对应的资源目录与基址定义);
  2. 收集各 CPK 的汉化产物,按纯数字文件名解析原 ID,映射为 patch_id;
  3. 构建 UTF 子表二进制,写回 Header 数值,拼接写入最终的 patch.cpk;
  4. 同步输出映射表(记录 patch_id, cpk, orig_id, size 便于溯源);

三、Nintendo Switch(Title ID: 01006DE00A686000)实现与踩坑

NS 版可执行文件为 64 位 ELF(转为 NSO)。底层所有文件查询集中在虚表派发的核心函数 0xA0494(elf vaddr)。因为上游全走虚表派发,无法在调用点打补丁,必须直接拦截函数入口。

注入方式

在可执行文件文本段(Text Segment)末尾追加一段 600 余字节的汇编代码。将 0x9E028(sys.cpk 绑定入口)和 0xA0494(核心查找函数入口)的首指令改写为 b <payload> 分支跳转。静态状态区放在未被引用的 BSS 头部(0x191CE8)。

联调排坑记录

1. 栈读写寄存器写反(Eden 静默吞掉,真机直接 Crash)

  • 现象:Eden 模拟器下汉化完全不生效,日志在 15 秒处密集打印 Unmapped Read64 @ 0x0;真机加载直接报错崩溃。
  • 原因:绑定钩子进入时使用 stp x1, x2, [sp, #0x30](x1 存在 0x30,x2 存在 0x38),但取路径指针时写成了 ldr x10, [sp, #0x30],读到了 x1。绑定函数被调用时 x1 固定为 0(空指针)。Eden 对未映射读取默认返回 0,导致后续路径字符串比较恒不成立,从未绑定补丁包;而实机无法忽略空指针读,直接触发访问异常崩溃。
  • 修复:直接使用入参寄存器 x2 取值,并加入空指针守卫;在打包脚本中加入反汇编自检,确保所有的压栈和出栈寄存器/偏移 1:1 强校验。

2. 手写十六进制常量的字节序陷阱

  • 现象:第一步修复后模拟器不再报错,游戏正常运行,但依然完全不显示汉化。
  • 原因:绑定钩子判断路径是不是 rom:/sys.cpk,在比对 .cpk 4 字节后缀时,小端编码原本应为 0x6B70632E,汇编源码手写成了 0x706B632E(实际对应 .ckp)。由于比对恒不匹配,patch.cpk 从未被绑定。
  • 修复:不再手写立即数,改由构建脚本直接根据字符串字节序列自动生成 movz/movk 汇编指令。

3. CRI 查询函数的真实“未命中”判定

  • 现象:补丁存在时进游戏黑屏无响应;如果删除 patch.cpk,游戏却能正常以日文原版进入。
  • 原因:查询钩子最初只使用返回值 w0 == 0 作为查到了补丁的判定条件。实际上,CRI 的底层查询函数在没有查到文件时也会返回 w0 = 0。真正的未命中标志是输出结构体 param+0x18 == 0 或等于未命中标记魔数 0x5047CBE0。重查未命中时,钩子误以为命中,提前返回,上层拿到未初始化句柄访问崩溃。
  • 修复:补全原版包装的三条件校验:w0 == 0 且 param+0x18 != 0 且 param+0x18 != 0x5047CBE0,缺一不可。

4. 栈备份区域过大破坏了调用方上下文

  • 现象:第一次重查回落后约 2 毫秒,程序突然开始向栈上狂写约 7MB 的代码数据,随后卡死。
  • 原因:查询钩子在进行重定向之前,将 param 整段 0x150 字节全部备份到栈上,准备失败时写回。但部分调用方的 param 位于其栈帧末端,大范围的内存写回越界破坏了调用方原本保存的返回地址和寄存器,导致后续逻辑中 memcpy 的参数彻底失控。
  • 修复:重查只需要备份被我们改动的两个字段:param+0x10(ID)与 param+0x140(候选数),其余字段完全不碰。

5. BSS 初始状态差异(实机内存不保证清零)

  • 现象:Eden 模拟器进游戏一切正常,真机上触发 nn::diag::Abort 用户异常崩溃。
  • 原因:存放 patch_binder 与状态的静态地址位于 0x191CE8,处在可执行文件 MOD0 声明的 BSS 真实起点之前。模拟器默认分配的是零页,而实机上是一片残留内存。真机启动时 state 恰好是残留脏数据,导致查询钩子将一个随机堆指针误当作 patch_binder 去解引用。
  • 修复:在绑定钩子入口处增加显式清零逻辑,并引入类似 NSPL 的 32 位 Magic 校验字段,只有完成初始化才激活重定向。

四、PlayStation Vita(Title ID: PCSG00505)实现与踩坑

PSV 版可执行文件为 32 位 ARMv7-A 架构的 eboot.bin,支持 ARM/Thumb-2 混编。与 NS 版不同,PSV 版的查找入口只在 4 个确定的 Thumb bl 调用点出现,可以直接改写调用点。

PSV 实机上的内存限制比 NS 严格,调试也花了相当长的时间。

1. 寻找注入区:遗留在二进制里的《Root Double》

PSV 的 eboot.bin 经过签名打包为 FSELF。最初我们尝试扩展 RX(代码段)尾部的空隙(修改 ELF Program Header 的 p_memsz),Vita3K 模拟器运行正常,但PSV 真机加载器拒绝加载改了段大小的 FSELF。

必须在原版 RX 镜像内寻找无用的空白区域(Code Cave)。

移植开发商在编译本作时,将另一部视觉小说《Root Double -Before Crime * After Days-》的整套 TIPS/词汇表系统残留在可执行文件里(RX 段 0x810A95F0 处是一段长达 1KB+、解释 INES 国际核事件分级的词汇表条目;前文还包含了琴乃悠里、橘风见等全部角色名)。

经过交叉引用分析,本作(大图书馆的牧羊人)的代码逻辑完全不会访问这套词汇表。我们直接将 0x810A95F0 起的 0x600 字节当作注入代码洞,在不修改 ELF 段表大小、不移动文件偏移的前提下完成了注入。

2. Thumb / ARM 切换与保护寄存器

查找调用点是 16 位/32 位 Thumb 指令,而我们手写的 Payload 采用 ARM 状态。在调用点改写为 16 字节跳转块(Trampoline)进行状态切换:

push {r3}
add  r3, pc, #imm
bx   r3
  • 踩坑:早期第一版直接使用 ldr r3, [pc, #0],覆盖了 r3 寄存器。而在 CRI 的绑定调用点中,r3 正好是第 4 个入参(work 缓冲区指针,0 表示引擎内部动态分配)。r3 被改写为跳转目标后,底层误以为传入了非法的外部 work 地址,直接报错 The designate work size is too small 并 abort。因此进入跳转块必须先 push {r3},跳入 ARM 代码第一条指令后再 pop {r3} 还原。

3. 真机模块 ASLR(模拟器与硬件的最隐蔽差异)

  • 现象:Vita3K 模拟器下汉化运行完美,真机部署后一启动直接报 0x30002(Undefined Instruction)崩溃。
  • 原因:抓取真机的 Core Dump 分析发现,主模块 dh 的段基址在每次启动时都会随机化,RX 代码段与 RW 数据段各自独立随机偏移,两者间的相对距离也是不固定的。Vita3K 总是把段加载在 ELF 头声明的固定虚拟地址(0x81000000),因此掩盖了问题;而实机上,原本 Payload 里写死的绝对跳转地址(如 ldr r3, =0x810A983C)和 RW 槽位表地址跳到了未被重定位的区域,直接将原始字符数据当作 ARM 指令解码执行,触发未定义指令异常。
  • 修复:
    1. Payload 内部的全部跳转改用 PC 相对寻址(add r3, pc / blx);
    2. 运行时定位 RW 槽位表:动态读取启动流程中已被系统重定位好的 movw/movt 指令对(例如 0x81000CEE 原本取槽位表的指令),现场解析出当前运行期实际的 RW 段绝对基址,再进行相对偏移计算出槽位表与静态数据区。

五、最终交付成果与对比

经过针对真实硬件环境的持续修复,两个平台最终均实现了轻量化的补丁挂载方案:

平台原始全量替换包体积单 patch.cpk 注入方案体积
PS Vita约 1.3 GB约 75 MB(截至本文编写时)
Nintendo Switch约 7 GB(仅 bg.cpk 就高达 6.6GB)约 257 MB(截至本文编写时)

方案收益

  1. 网络分发压力大幅下降:发布补丁时不需要再打包、上传、下载数 GB 的游戏大包,无论是测试还是分发,下载几十到几百兆的补丁即可。
  2. 纯差量与降级安全:未汉化的资源完全保留在官方原版 CPK 内,按原样流程读取;若玩家不慎删除了 patch.cpk 或重命名,钩子会自动识别绑定失败并进入 Bypass 状态,游戏自动退回日文原版状态,不闪退、不崩溃。
04:13