Kitmoe

sakuranouta10th 的简单汉化方法

本文介绍sakuranouta10th重制版游戏的汉化方法,通过分析EXE文件结构和日语解密函数,完成PFS包内容的解密;通过分析引擎加载行为,实现对游戏资源的汉化。

本文承接上文,对重制版游戏进行汉化。

EXE 静态分析

节 虚拟地址 虚拟大小 原始大小 熵 说明
.text 0x1000 0x29F240 0x29F400 6.49 主代码
.rdata 0x2A1000 0x1969C2 0x196A00 4.99 只读数据、字符串、RTTI
.data 0x438000 0x26680 0x22400 4.55 可写数据、vtable、全局对象
.pdata 0x45F000 0x1AA48 0x1AC00 6.26 异常处理表
.text(小) 0x47A000 0x420 0x600 1.40 附加代码
_RDATA 0x47B000 0x1F4 0x200 4.23 只读数据
.rsrc 0x47C000 0x6ECF1 0x6EE00 6.36 图标、清单

1.1 保护与完整性

  • 未加壳:节名正常,最大熵 6.49,导入表完整可读,存在 PDB 路径;没有 UPX/VMProtect/Themida 等特征。
  • Overlay 只有 Authenticode 证书:安全目录位于 0x4E2800,大小 0x5170,正好到文件尾 0x4E7970;没有附加可疑数据。
  • TLS 回调为空:TLS 目录存在,但 AddressOfCallBacks 指向空指针,没有反调试 TLS。
  • DllCharacteristics = 0x8120:NX_COMPAT、GUARD_CF、HIGH_ENTROPY_VA;未置 DYNAMIC_BASE,所以固定基址、无 ASLR。
  • 无导出表、无 CLR/.NET 头;mscoree.dll 只是 CRT 相关字符串,不是 .NET 程序。
  • IsDebuggerPresent 在导入表中,但属于 CRT 启动代码的常规导入;没有 CreateRemoteThread、WriteProcessMemory、NtUnmapViewOfSection 等注入 API。
  • PDB 路径:C:\Users\mikage\Documents\MyDevEnv\Projects\Win32\Artemis\x64\Release\Artemis.vs2022.pdb
  • 调试目录类型: 2(CodeView)、12、13、14(VC 特性/优化信息)。

居然连 PDB 路径都没删,PDB 路径直接暴露了引擎工程名 Artemis 和开发者用户名 mikage,与 iMel 的 Artemis 引擎一致。

1.2 日语解密函数的分析

没加壳的情况下,解密函数只要静态分析就可以得到。

很容易注意到日语解密函数:

0x140188180  mov  [rsp+0x20], rbx
...
0x1401881a0  call qword ptr [rax+0x38]      ; tell() -> eax(当前文件位置)
0x1401881aa  mov  esi, eax
0x1401881ac  sub  r9d, eax
...
0x1401881b6  mov  r9, [rdi+0x10]            ; FILE*
...
0x1401881ca  call 0x140255C38               ; fread(buffer, 1, size, FILE*)
...
0x1401881d7  mov  rax, [rdi+0x88]            ; 解密器/密钥对象
0x1401881e3  cmp  byte ptr [rax+0x50], 0    ; 是否加密
0x1401881e9  cmp  byte ptr [rdi+0x98], 0    ; 是否跳过解密(散装/未加密)
0x1401881f2  lea  r9, [rax+0x54]             ; 20 字节 key
0x1401881f6  mov  eax, 0x66666667
0x1401881fb  imul esi
0x1401881fd  sar  edx, 3
0x140188207  lea  eax, [rdx+rdx*4]
0x14018820c  shl  eax, 2
0x14018820f  sub  esi, eax                   ; esi = tell % 20
...
0x140188220  movzx ecx, byte ptr [rax+r9]    ; key[index]
0x140188227  xor  byte ptr [rbx], cl         ; 逐字节 XOR
0x140188229  lea  rbx, [rbx+1]
0x14018822d  lea  rcx, [rax+1]
0x140188231  xor  eax, eax
0x140188233  cmp  rcx, 0x14                  ; 20
0x140188237  cmovl rax, rcx                  ; index 0..19 循环
0x140188240  jb   0x140188220

设:

  • cipher:PFS 条目在文件里的原始字节;
  • key:20 字节密钥;
  • tell:本次 read 开始时 FILE* 的位置(对条目从头读时为 0)。

则:

plain[i] = cipher[i] ^ key[(tell + i) % 20]

对单个条目一次性读取时,tell = 0,即:

plain[i] = cipher[i] ^ key[i % 20]

这是一个固定 20 字节循环 XOR。密钥每 20 字节重复一次。并且解密过程是可以跳过的:

  • [decoder+0x50] == 0:不加密(直接返回 fread 的数据);
  • [pack+0x98] != 0:跳过解密(特殊条目等);
  • 否则执行 XOR。

解密循环用:

0x1401881d7  mov rax, [rdi+0x88]     ; 解密器对象
0x1401881f2  lea r9, [rax+0x54]      ; key 基址

即:pack->decoder(偏移 +0x88)的 +0x54 处,20 字节。

密钥生成:`SHA1(pfs[7 : 7+dataOffset])

函数 0x1401884A0 中,设置加密标志并计算 SHA-1:

0x140188b40  mov  byte ptr [rcx+0x50], 1     ; 标记“已加密”
0x140188b44  mov  dword ptr [rbp+0x70], 0x67452301
0x140188b4b  mov  dword ptr [rbp+0x74], 0xEFCDAB89
0x140188b52  mov  dword ptr [rbp+0x78], 0x98BADCFE
0x140188b59  mov  dword ptr [rbp+0x7c], 0x10325476
0x140188b60  mov  dword ptr [rbp+0x80], 0xC3D2E1F0

这五个常量就是 SHA-1 的标准初始值。然后:

  • fread 从文件偏移 7 读取 dataOffset 字节到缓冲区;
  • 按 64 字节块调用 SHA-1 transform(0x140017800);
  • 在 0x140188C46 调 0x140018380(SHA-1 final),输出 20 字节到 pack+0x54;

公式:

dataoff = u32 little-endian at pfs[3:7]
key     = SHA1( pfs[7 : 7 + dataoff] )

直接得到:

卷 dataOffset 密钥(hex)
sakuranouta10th.pfs 0x14FA5 f26f564e18b224771178cd0406c8e8d9cf6db76f
.pfs.000 0xEB38F fcb12ab1276f004e3da819576dba252a88645572
.pfs.001 0xF312C 50093b4332631bd0f72621aeae0d39d81bd1a37f
.pfs.002 0xBEEC 052bf68f1fa208b3480edf9d0ee40e5c6a79e071
.pfs.003 0x579F 2316e30429f6294c14cf34b5f58bed2fc341b67f
.pfs.500 0x286C4 72fcf4842efcda88cc7f4ec39b104ee51bb9bf1e
......

日语解密与提取

由上可知,引擎的加密系统和漏勺一样,不过这对我们来说是好事。

用密钥解 script\01_fruhlingsbeginn_05_1.ast 得到日语脚本结构:

astver = 2.0
astname = "ast"
ast = {
block_00000={
{"savetitle",text="第Ⅰ章"},
{"bg",file="ev_eye_c02_01",path=":ev/ev_eye_c02/",set="ev_eye_c02"},
...
text = {
    vo = {
        {"vo", file = "fem_mkd_000700", ch = "mkd"}
    },
    ja = {
        {
            name = {"真琴"},
            "「本当に良いの?」"
        }
    }
},
...

解出的日文对白示例:

「本当に良いの?」
「何がですか?」
「稟ちゃんまでモデルやるって話。稟ちゃんは新入部員だから、今年は歓迎される側なのに」
「あれ? 新入部員だとしても私はもう弓張学園美術部の部員ですよねぇ。

用同一密钥解 system\table\list_windows_ja.tbl 得到日语 UI:

lang = {
    uihelp = {
        save = {
            save = "[num]にデータを保存します。",
            save2 = "[num]にデータを上書きします。",
            edit = "[num]のコメントを編集します。",
            move = "[num]のデータを移動します。",
            del = "[num]のデータを削除します。",
            bt_page01 = "ページ1に移動します。",
            ...

用 .pfs.500 密钥解 script\110_cordyceps_01.ast 得到 10 周年追加篇:

astver = 2.0
astname = "ast"
ast = {
block_00000={
{"savetitle",text="APPEND"},
{"bg",file="eye_riyu",id=9,lv=9,path=":cg/"},
...

解密代码:

import struct

def parse_pfs(path):
    f = open(path, "rb")
    magic, dataoff, count, flags = struct.unpack("<3sIHH", f.read(11))
    assert magic == b"pf8"
    entries = {}
    f.seek(11)
    for _ in range(count):
        n = struct.unpack("<I", f.read(4))[0]
        name = f.read(n).decode("utf-8", "replace")
        f.read(4)  # padding
        off, size = struct.unpack("<II", f.read(8))
        entries[name] = (off, size)
    return entries

def decrypt(data, key):
    return bytes(b ^ key[i % 20] for i, b in enumerate(data))

def recover_key(cipher, plain):
    return bytes(c ^ p for c, p in zip(cipher[:20], plain[:20]))

KEY_PFS = bytes.fromhex("f26f564e18b224771178cd0406c8e8d9cf6db76f")
KEY_PFS500 = bytes.fromhex("72fcf4842efcda88cc7f4ec39b104ee51bb9bf1e")

汉化实现

3.1 引擎汉化方法

注意到: 0x1401872B0 的逻辑可以还原为:

bool CArtemisPackFile::open(const char* path)
{
    // 1. 规范化路径:'/' -> '\\'
    normalize(path);

    // 2. 先尝试磁盘上的散装文件
    if (CFile::open(path) == 0) {       // fopen(path, "rb") 成功
        // 0x1401873CA:使用这个 FILE*,计算大小、设置对象字段
        return true;
    }

    // 3. 磁盘上没有,回退到根包 root.pfs
    //    0x140187486
    pack = getPack("root.pfs");         // vtable + 0x78
    entry = pack->find(path);
    if (!entry)
        return false;

    return pack->openEntry(entry);      // 读取并按 20 字节 key 异或解密
}

关键分支在:

  • 0x1401873BD:call 0x14016DB10(fopen)
  • 0x1401873C2:test al, al
  • 0x1401873C4:jne 0x140187486(打开失败 → 包回退)
  • 0x1401873CA:成功路径(继续用散装文件)
  • 0x1401874C7:call qword ptr [rax+0x78],参数含字符串 root.pfs

CFile::open(0x14016DB10)内部:

  • 若对象已有文件,先关闭;
  • 路径为空返回 1;
  • 否则调用 CRT fopen(0x140264AD0 / 0x140264ADC),模式 "rb";
  • 成功返回 0,失败返回 1。

那就简单了,引擎加载资源是是散装文件优先,新的散装文件会覆盖在游戏打包的资源之上,那么我们只要在磁盘上创建散装文件就可以直接完成汉化。

3.2 旧汉化匹配新汉化的方法

这个没什么好说的,二板斧:

  1. 通过场景名称、说话人等资源结构匹配一批
  2. 通过完全相同的新旧日语匹配一批

最终结果:

来源 行数 占比
老版人工汉化 51,612 79.2%
缺失(LLM 补全) 13,535 20.8%
合计 65,147 100%

主要是以下原因:

  1. 老版汉化未汉化的 H 章节
  2. 新版新增的章节与对话
  3. 新版微调的对话

最后把中文编译回脚本,塞成散装文件,就可以完成汉化了。

另外游戏原版的 UI 好看是好看,但是可读性实在是灾难,暴力修改了一番。

最终测试

图片说明

图片说明

本文授权:CC BY-NC 4.0 · 署名—非商业性使用

输入关键词,开始搜索。