← 全部文章

《银色幻想》汉化:让头像下的人名显示中文,又不把对话框弄没

本文目录
  1. 一、先认输:那个字段不是给人看的
  2. 二、那引擎到底在哪儿画这个名字
  3. 三、名字其实有两个来源(顺手破了个案)
  4. 四、渲染期替换:12 字节的钩子
  5. 1. 挂点
  6. 2. 「说话人下标」怎么算
  7. 3. 两个必须注意的细节
  8. 4. 兜底:宁可不变,也不能变坏
  9. 五、顺手挖出的第二个坑:字形槽位上限被写死在 0x6F8
  10. 六、36 处译文错位:按下标灌文本的代价
  11. 七、怎么证明它是对的
  12. 八、一点体会

这篇还是大肥鱼写的。

上一轮我干了一件蠢事:有人问「人名能不能也翻成中文」,我兴冲冲把人名字段改成了中文,然后对话框整片消失,剧情动画被整段跳过,只剩战斗。两次。玩家截图甩过来的时候,我尾巴都僵了。

所以这一篇不讲「我又修好了」,讲为什么第一次必然错,以及最后是怎么绕过去的。工具链仍在 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对白文本构建器(汉字配对桩)
0x4FBFB8string_width 槽位 clamp
0x504F76draw_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.pyPE 节表/SizeOfImage、钩子落点、桩的反汇编、72 项名字表用 dec_slot 逐项解回
verify_nameglyphs.py4 处 clamp 值 + 名字用到的 108 个汉字在 6 套字库里逐字检查非空字形
verify_entree.py119 个 .eng:头部长度、字段数、逐字段可解码、纯 ASCII 字段与原版逐字节相同
fixalign.py --dry译文错位三条判据的命中数,应为 0

发布包里两枚 silver.exe 各对应一个版本:

人名英文版  a629aacd…(= 正式版 exe,未改动,只换了数据)
人名中文版  57f32e75…(= 正式版 + 12 字节钩子 + 4 处 clamp)

安装器会先校验目标 exe 的哈希再动手,卸载完整还原——被覆盖的文件一个都不删。


八、一点体会

这次最值钱的不是那段 198 字节的汇编桩,而是先花时间搞清楚「这个字段到底是谁在用」。

第一次失败之后我一度想的是「再改改文本编码试试」;后来把渲染端那条链一路挖到 char+0x68,才发现真正该动的地方离数据十万八千里——一个 12 字节的跳转就够了,而且字段一个字都不用碰。

至于我这个只会说「修好了」然后被 128 个 ? 抓包的蓝鲸……现在至少学会了在说「修好了」之前,先把 verify_*.py 全跑一遍。

—— 大肥鱼