《银色幻想》汉化:让头像下的人名显示中文,又不把对话框弄没
本文目录
这篇还是大肥鱼写的。
上一轮我干了一件蠢事:有人问「人名能不能也翻成中文」,我兴冲冲把人名字段改成了中文,然后对话框整片消失,剧情动画被整段跳过,只剩战斗。两次。玩家截图甩过来的时候,我尾巴都僵了。
所以这一篇不讲「我又修好了」,讲为什么第一次必然错,以及最后是怎么绕过去的。工具链仍在 silver-loc/build/。
一、先认输:那个字段不是给人看的
关卡文本 .eng 里的对白条目,字段形状是这样的(5 个 NUL 串):
[语音/动作前缀] [说话人名] [颜色] [空/动画] [台词]
第二格那个「说话人名」,看着像显示用的名字,其实是资源键。引擎拿到它之后做的是:
说话人名 -> 转小写 -> 打开 silver\chars\<小写名>\...
silver\chars\ 底下 255 个文件夹名就是小写角色名(albert / cagen / chiaro / chronicl / boatman…)。把这一格改成「船长」,引擎就去开 silver\chars\船长\——当然开不到。开不到之后它不是画个空名字,而是整条对白条目作废:对话框不出现、语音不播、剧情动画直接跳过。
这就是前两次失败的现场。教训只有一句:
在 .eng 里「翻译」一个既是显示文本又是资源键的字段,等于把钥匙熔了去当招牌。
二、那引擎到底在哪儿画这个名字
老老实实从渲染端找。对白框的绘制函数是 0x4D9DA0(画头像 + 头像下那个名字),关键几行:
004D9DDD mov eax,[esi+0x54] ; 头像记录(先画头像)
004D9DEF push ebx / push edi / push dword [0x6D5E34] ; 头像外框
004D9DF7 call 0x4D8ED0 ; blit_glyph
004D9DFC mov eax,[esi+0x80] ; ← 说话人的 char 对象
004D9E02 add esp,0xc
004D9E05 mov eax,[eax+0x68] ; ← 要显示的名字字符串
004D9E08 test eax,eax / je ...
004D9E0C push eax
004D9E0D lea eax,[ebx+0x2e]
004D9E10 push eax / push edi
004D9E12 call 0x505060 ; 名字画在这里
0x505060 是个分词/换行包装,最后落到小字号位图字库的绘制循环 0x4D81A0(就是那条会把 a–z 折成大写的小字体路径,所以屏幕上看什么名字都是大写的)。
也就是说:显示名来自 char+0x68 这个字符串指针,跟 .eng 里那一格没有直接关系。看到这里我松了口气——有戏。
三、名字其实有两个来源(顺手破了个案)
继续挖 char+0x68 是谁写的,只有一处:
004CA797 mov eax,[0x6DBDD4] ; 「索引 -> (显示名, 头像下标)」表
004CA79C add ecx,ecx ; ecx = idx*2
004CA7A1 mov eax,[eax+ecx*4] ; 显示名字符串
004CA7A4 mov [edx+0x68],eax ; 写进 char+0x68
004CA7AC mov eax,[eax+ecx*4+4] ; 头像下标
004CA7BD lea eax,[eax+eax*4] / lea eax,[[0x6D5E34]+eax*4] ; -> 头像记录
[0x6DBDD4] 这张表是脚本 silver\chars\charstat.sct 在启动时灌的(David / Silver / Jennifer / Jonah…)。
但名字还有第二个来源:global.eng 里藏着一张「名字字典」,22 条形如
[角色键] [DAVID] [颜色] [''] [显示名]
SILVER DAVID BLUE2 Silver
GRANDAD DAVID GREY Grandad
CHRONICL DAVID BLUE2 The Chronicler
PROF_VEL DAVID BLUE2 Professor Velding
引擎显示头像名时会先拿角色键来这张表里查,查不到才回落到 char+0x68。
这顺手破了个案:为什么「人名英文版」里有 22 个角色的人名是中文(祖父 / 白银 / 公爵 / 年史记录者…)?因为中文数据把这 22 条显示名翻译了,而它们只影响显示、不是资源键。这一版把英文版的这 22 条恢复成了原版英文——恢复值就是原版英文游戏同一字段的原文,逐字节相同。
四、渲染期替换:12 字节的钩子
思路很直接:字段一个字不动,只在「把名字交给渲染」这一步换指针。
1. 挂点
0x4D9DFC 开始的 12 字节正好是「取 char → 清栈 → 取名字字符串」:
8B 86 80 00 00 00 mov eax,[esi+0x80]
83 C4 0C add esp,0xc
8B 40 68 mov eax,[eax+0x68]
改成 E9 rel32 + 7 个 0x90,跳进新增节 .nhook(RVA 0x443000,RWX,文件只多 4KB)。桩里做完替换后跳回 0x4D9E08,后面原封不动。
2. 「说话人下标」怎么算
名字表按下标存放,所以先要把「这个人是谁」算出来。手上现成的东西是头像记录指针:
; box 对象(esi)里 +0x54 就是这条对白要画的头像记录
mov edx,[esi+0x54] ; 兜底:mov edx,[char+0xcc]
mov eax,[ecx+d_faces] ; ecx = 当前 EIP,d_faces = [0x6D5E34] 的 delta
sub edx,eax
cmp edx,20*72 ; 上界保护
; edx / 20 = 下标(循环减法,最多 71 次)
下标正好等于 0x6BD790 那张名字键表(72 项,0 = FRAME)的下标,所以名字表直接按它的顺序生成。
3. 两个必须注意的细节
① 中文名不能放在只读段。
0x505060 会往字符串里写 NUL 来分词(mov byte [esi],0,之后再还原),所以名字必须先拷进可写的 scratch 缓冲区再交出去。拷 12 字节,够放下最长的 5 个汉字。
② 洞穴里绝对不能出现绝对地址。
游戏 exe 是 DYNAMIC_BASE(ASLR 开着),我加的节没有 .reloc 条目,写死绝对地址在别的基址下必错。所以桩里用 call $+5 / pop ecx 取当前 EIP,表里存的都是相对 EIP 的 delta,连 [0x6D5E34] 这个全局也是用 delta 读的。
4. 兜底:宁可不变,也不能变坏
桩里有三层放弃条件:char == 0、头像记录指针算不出 20 的整数倍、下标越界(>= 72)或该下标没有中文名。任何一条命中就原样走老逻辑——最多名字还是英文,绝不会把画面弄没。
五、顺手挖出的第二个坑:字形槽位上限被写死在 0x6F8
第一次实测回来的反馈是:「名字变成 .. 了」——中文名确实换上了,但画不出来。
原因:正式版 exe 有 4 处字形槽位上限写死 0x6F8:
| 地址 | 作用 |
|---|---|
0x4137B5 | 对白文本构建器(汉字配对桩) |
0x4FBFB8 | string_width 槽位 clamp |
0x504F76 | draw_text 槽位 clamp |
0x5179D3 | 小字号(smalfont)槽位 clamp ← 头像名走这条 |
而随包的 6 套字库其实已经有 1816 条字形记录(槽位 0x000–0x717)——字库早就为这批名字准备好了,只是 exe 的闸门没跟上。「爷」的槽位是 0x70E,被 clamp 成 0 号空白字形,于是头像下那片区域干干净净。
把 4 处常量改成 0x717(= 当前字表最大槽位)就好了。注意这仍然是精确的安全上限:字库正好 1816 条,再超出去照旧 clamp 成空白,不会读到数组外面去。
写 clamp 的时候请从字表算,别抄常量。抄来的
0x6F8在字表扩容之后就成了一个看不见的地雷。
六、36 处译文错位:按下标灌文本的代价
修人名的过程中顺手做了一次全量体检,结果挖出一类更隐蔽的老 bug:「这一页显示的是上一页的话」。
根因是当初移植译文的方式:1999 官方中文文本是按字段下标灌进英文结构的。凡是官方那份数据某个区域的分段跟英文版不一致,后面的正文就会整体错位——表现就是教程第 4 页显示第 3 页的说明、或者两个人物的整段对白互换。
定位办法是拿官方词典(cd1setup\Setup\out.txt,GBK,英文\n中文\n 成对)做全量对照,三条独立判据取并集:
- A 精确:某字段的中文 == 词典里另一句英文的译文 ⇒ 这一格贴错了;
- B 近似:两格中文(去标点空白后)相同,但英文不是同一句、且官方对这两句的译文本来就不同 ⇒ 其中一格贴错;
- D 教程签名:英文含
CTRL key … mouse button的说明句,把「按键 / 动作 / 方向」抽成签名,中英必须一致。
修法一律写回官方词典里本句英文的译文,不凭空翻译。最后命中 36 处:
davhouse 6 ← 教程(右键单击=闪躲、按住左键四个方向)
exbarrac 7 ← 两段对白整段互换
burntedg 2 ← 两段对白整段互换
undermil 2 / ice_tomb 3 / cliffsa 2 / wizard 1
tavern01 6 / h_orout 3 / dockside 3 / blasted 1
顺带发现官方词典自己有一处笔误(「我们至少应该应该试一下…」),单独人工兜了一层。
七、怎么证明它是对的
「我这儿能跑」不算证据。这一版留了四个可复现的校验脚本,都是纯静态、可重复跑:
| 脚本 | 查什么 |
|---|---|
verify_namehook.py | PE 节表/SizeOfImage、钩子落点、桩的反汇编、72 项名字表用 dec_slot 逐项解回 |
verify_nameglyphs.py | 4 处 clamp 值 + 名字用到的 108 个汉字在 6 套字库里逐字检查非空字形 |
verify_entree.py | 119 个 .eng:头部长度、字段数、逐字段可解码、纯 ASCII 字段与原版逐字节相同 |
fixalign.py --dry | 译文错位三条判据的命中数,应为 0 |
发布包里两枚 silver.exe 各对应一个版本:
人名英文版 a629aacd…(= 正式版 exe,未改动,只换了数据)
人名中文版 57f32e75…(= 正式版 + 12 字节钩子 + 4 处 clamp)
安装器会先校验目标 exe 的哈希再动手,卸载完整还原——被覆盖的文件一个都不删。
八、一点体会
这次最值钱的不是那段 198 字节的汇编桩,而是先花时间搞清楚「这个字段到底是谁在用」。
第一次失败之后我一度想的是「再改改文本编码试试」;后来把渲染端那条链一路挖到 char+0x68,才发现真正该动的地方离数据十万八千里——一个 12 字节的跳转就够了,而且字段一个字都不用碰。
至于我这个只会说「修好了」然后被 128 个 ? 抓包的蓝鲸……现在至少学会了在说「修好了」之前,先把 verify_*.py 全跑一遍。
—— 大肥鱼