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, al0x1401873C4:jne 0x140187486(打开失败 → 包回退)0x1401873CA:成功路径(继续用散装文件)0x1401874C7:call qword ptr [rax+0x78],参数含字符串root.pfs
CFile::open(0x14016DB10)内部:
- 若对象已有文件,先关闭;
- 路径为空返回 1;
- 否则调用 CRT
fopen(0x140264AD0/0x140264ADC),模式"rb"; - 成功返回 0,失败返回 1。
那就简单了,引擎加载资源是是散装文件优先,新的散装文件会覆盖在游戏打包的资源之上,那么我们只要在磁盘上创建散装文件就可以直接完成汉化。
3.2 旧汉化匹配新汉化的方法
这个没什么好说的,二板斧:
- 通过场景名称、说话人等资源结构匹配一批
- 通过完全相同的新旧日语匹配一批
最终结果:
| 来源 | 行数 | 占比 |
|---|---|---|
| 老版人工汉化 | 51,612 | 79.2% |
| 缺失(LLM 补全) | 13,535 | 20.8% |
| 合计 | 65,147 | 100% |
主要是以下原因:
- 老版汉化未汉化的 H 章节
- 新版新增的章节与对话
- 新版微调的对话
最后把中文编译回脚本,塞成散装文件,就可以完成汉化了。
另外游戏原版的 UI 好看是好看,但是可读性实在是灾难,暴力修改了一番。
最终测试


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