最近忽然心血来潮想要试着汉化游戏,不过在热情过去后仔细想想工作量感觉不像是我这个外行人能够搞定的——即使是程序的工作就有找到文本以及字库在ROM中的位置,在保证游戏正常运行的情况下把这些替换成中文,如果字库容量不够还需要扩容,可能需要修改绘制代码……想想就令人头大。
不过,在思考可能会遇到的问题的时候我也想到了另一个方案,正如RetroAchievements或者是RTA比赛时用到的计时器会通过读取游戏内存来获取玩家的进度,我们是否可以通过读取游戏的内存来获取到当前屏幕上显示的文字呢?
这个方案的优点十分明显,首先不需要修改原本的游戏ROM,难度骤减,也不会引入BUG。第二外挂汉化不会受到游戏内字库容量限制,想怎么写就怎么写。甚至还规避了版权问题(也许吧,IANAL)。当然缺点就是看起来不太美观,不太好导出全部文本提前汉化,更适合使用AI等工具即时汉化。
无论如何,既然决定了方向,那么就先朝着它撞上去试一下。
工欲善其事,必先利其器
第一步我们要决定用什么模拟器。首先这个模拟器需要支持读内存,最好支持插件让我们可以直接通过编写插件来获取模拟器内存状态。
然后它需要有良好的debugger支持。毕竟分析游戏内存设置断点什么的那是必不可少的。
我第一个选定的是一个用于TAS的多核心模拟器BizHawk。主要是看中了它支持内存读写和Lua以及C#插件。而且它好像也支持Debugging。
可惜下下来后我直呼上当,PS1的Debugger功能直接就是灰的,内存读写也十分简陋。不过它的插件系统看上去倒是还算可堪一用,也许等之后我们游戏分析完毕会把它当做实际插件运行的平台,不过目前它是帮不上什么忙了。
然后我试了下当前PS1模拟的当红炸子鸡(鸭?)DuckStation。也是在介绍页面提到了Debugger支持,不过等下下来之后我甚至没有找到Debugger在哪里。官网上也完全没有Debugger相关的文档。而且这个项目的开源License是禁止修改后发布的,这让我之后基于它写插件(并公开)都(在法律上)做不到,直接PASS。
最后我找到了一篇RetroArchevements的相关人员介绍如何去找PS1游戏关键信息内存地址的文章(文章地址见下),它推荐了PCSX-Redux,一款基于PCSX的PS1开发研究工具集。(实际上就是一个主体的模拟器加上一些好用的工具。)
同时,在这篇文章中作者也介绍了反编译工具Ghidra及其PS1插件ghidra_psx_ldr,这里我们也一并下载下来。
磨刀不误砍柴工
有了工具,我们还需要拥有足够多的前置知识。对PS1的大体架构有所了解,PS1的架构知识相比于FC和GBA在网上要更少一些,不过我还是找到了一个相当详细的介绍:
https://psx-spx.consoledev.net/
(顺带一提本文的原作者似乎过得很不好,如果有经济能力的读者可以去支持一下……虽然这么说作者的官网已经挂了,我找了半天也只找到了一个最后更新是18年的patreon网站,不是很确定作者还能不能收到钱……)
以及上文提到的RetroAchievements相关人士撰写的实操教程:
https://suxin.space/notes/tracking-down-playstation-pointers-using-debuggers-ghidra/
psx-spx中需要注意的部分主要集中在GPU和DMA Channels上,我们之后分析内存也主要从这两处入手。同时可以注意到,PS1和之前的FC以及GBA不同,它的GPU已经变成指令式的了,不再是FC或者GBA那种把数据塞到指定的VRAM位置下GPU会自动去绘制。
在读完这部分内容后,我们便可以开始着手分析PS1游戏了,这里我们以《侦探神宫寺三郎——在灯火消逝之前》为例。
第一步:从GPU入手
打开 PCSX-Redux,你需要在菜单 Configuration > Emulation
下,禁用Dynarec CPU,勾选 Enable Debugger。
然后将游戏的cue文件拖入窗口,PCSX-Redux不会自动开始运行游戏,你需要在菜单 Emulation 下选择 Start Emulation ,游戏才会开始运行。
当游戏开始运行之后,我们首先将游戏运行到显示文本的地方。然后就要开始调试了。
首先我们通过 Debug > GPU > Show GPU Logger
菜单打开 GPU Logger,然后勾选上 GPU Logging,Replay Frame,Show Origins,最后勾选 Breakpoint on vsync。
此时,游戏会进入暂停,同时在下方会出现很多条目,这些其实就是CPU发往GPU的绘制指令。如图所示:
此时我们打开 Main VRAM Viewer(通过菜单 Debug > GPU > VRAM Viewers > Show Main VRAM Viewer
),然后在 GPU Logger 中取消勾选其中的一些指令,你会发现在 Main VRAM Viewer 中也会有对应的改变。
(请注意,在Main VRAM Viewer的左上角,有两个几乎一模一样的画面,这其实是为了双重缓冲,一个画面用于当前显示,另一个画面用于绘制。同时,你勾选或取消勾选GPU Logger中的绘制指令,也只会作用于其中一个画面。)
通过这种方式,我们能很容易找到用于绘制文字的指令,它应该是个Rectangles指令。展开它,然后点击 Go to texture,我们就能跳转到字库在 VRAM 的位置。
请注意,此时VRAM的展示方式变成了4bit模式,我们可以注意到此时在VRAM左上角的画面变宽且颜色不正确了,这是因为游戏画面是以16bit(RGB555+1bit mask)存储的,以4bit解析它会导致1个像素被转换为4个像素,自然会变宽。同时,4bit图片实际上是一种索引图片,每个像素仅存一个索引值,需要用CLUT也就是Color LookUp Table,调色板来决定每个像素具体是什么颜色。
这里,如果你切换CLUT(打开CLUT VIEWER后在窗口的菜单中选择Select a CLUT,然后在CLUT窗口中移动鼠标可以在Main VRAM Viewer中预览效果,通过点击可以固定当前的CLUT)可以发现,其实本作的文字是在一个位置同时存储了两种不同的文字,通过切换CLUT,可以控制显示的是哪一个文字。这是一种很巧妙的节约内存的方法。因为图片是4bit,我们实际上可以在一个位置同时存储最多4个文字。
注意,这里绘制文字的指令前面会有一句Chain DMA from XXX,这句话的意思是 这个指令是通过链表DMA传入的,具体的链表DMA请参考psx-spx的DMA文档。如果你感兴趣的话,你可以从绘制文字的指令往上,找到最接近它的DMA Setting指令,这就是DMA的开启指令。然后通过点击该指令的PC = 后面的地址,你便能跳转到CPU用于设置DMA的代码位置。你甚至可以在此处下断点来研究游戏是如何传递DMA的各种参数的。因为这部分内容和接下来的分析无关,这里就不再展示了。
当我们找到绘制文字所用到的GPU指令之后,我们可以通过点击Chain DMA from 后面的地址获取到绘制指令在CPU侧内存的位置。也就是说CPU是在这个内存的位置放好了绘制用的数据,然后再调用DMA将这些数据传递给GPU。也就是说如果我们能够找到CPU是在哪里将这些数据写入该位置的,就能分析那附近的代码,找到游戏存储当前显示的文字数据的位置。
不过在此之前,先让我们分析此处的绘制指令:
开头的38b00a04
是DMA链表的header,0ab038是下一个节点的地址,04是本节点的长度。
接下来4个32bit word就是要传给GPU的内容了,80808064
是绘制Rectangle的指令,64转换为二进制为(01100100)b
,根据psx-spx的说法,这个指令代表着绘制一个 variable size,textured的rectangle。前面的808080
则是RGB颜色,80代表就按照纹理的颜色不进行改变(如果是索引纹理则是CLUT的颜色)。
再接下来的00380038
是矩形的显示位置。我们并不关心这个数据是怎么来的。
在接下来的ee38003f
是CLUT和UV值。003f
的部分是CLUT,二进制是(0011111100000000)b
,右边的6位是X=0,剩下的左边是Y=(0011111100)b
=252。这就是CLUT的坐标。
而ee38
就是纹理的UV,0xee
是X坐标,0x38
是Y坐标。(这里pcsx-redux会显示纹理的X坐标被右移了2位,这是因为pcsx-redux在计算坐标时是按照16bit为1单位计算的)。这里,纹理的坐标就确定了我们要在此处显示的文字是什么。所以,我们需要找到是谁修改了此处的内存。
Luckily,pcsx-redux提供了这样的工具。首先我们打开Breakpoints界面(Debug > CPU > Show breakpoints
),然后按下图新建一个断点。这样我们就可以在任何指令尝试对该处内存地址进行写入时中断程序的运行。
然后,我们取消GPU Logger的Breakpoint on vsync,点击resume继续程序的执行,等待断点被触发。
第二步:分析指令
现在,我们的程序断在了0x800180e8
。这条指令是将v0
的值写入[s0+0x0c]
所指向的内存地址。
然后,我们就可以分析这附近的汇编,然后快快乐乐地找到我们所寻的目标了?
可惜,汇编并非我的强项,在分析了半天汇编也没分析个所以然后,我决定投入Ghidra的怀抱。
1 | v0 = v1 * 4 // |
(我对这段汇编的分析……)
一开始,我本想让Ghidra通过gdb去直接连接pcsx-redux的GDB server,可惜没能成功。Ghidra总是报连接超时错误,可能和我的Python配置有些关系……但是我不太想继续往这个方向上花时间了,便决定按RetroAchievements那篇教程先把内存导出来进行静态分析。
首先,在 pcsx-redux 的 Configuration > Emulation
中勾选Enable Web Server。
然后访问 http://localhost:8080/api/v1/cpu/ram/raw 下载PS1的内存。
接下来,我们打开Ghidra,别忘了安装PS1插件。随便创建一个Non-shared工程,在Tool Chest中选择CodeBrowser,然后在弹出的窗口菜单中选择 File > Import File...
,选择刚刚下载的raw文件。然后按照教程中所说的Format 选择 Raw Binary
,Language选择PSX,Options中将baseAddress改成80000000
。
当程序通过弹窗询问是否进行Analysis时,选择Yes,这里我没有勾选Aggressive Instruction Finder,直接点击了Analyze,等待Ghidra分析完毕。
等Ghidra分析完毕后,我们选择Navigation > Go To...
,输入之前我们打断点的位置0x800180e8
。
虽然比汇编好看了一些,但是仍然不能准确理解……对0x144取模应该是因为整个页面总共有0x144个字(还记得之前说过的同一个位置可能会有多个字吗?同一个位置的不同字是算作不同页面里的,当然这里页面的概念是我自己定义的)。0x12是一行的文字个数,而’\x0e’是一个文字的长宽尺寸。但是上面那一坨究竟是什么,实在是有些难以分析。
不过,让我们把目光往下移,注意这里,puVar8[0xd] = (char)(((uint)uVar1 % 0x144) / 0x12) * '\x0e'
。这里0xd
是我们存储纹理Y坐标的地方,而这如此清爽的语句很明显地说明了uVar1
就是我们需要渲染的文字在文字表中的序号。
那么uVar1
怎么来的呢?
1 | uVar1 = *(ushort *) |
还是一样……虽然这样倒是也让我们能够弄懂上面的X坐标在算什么了。
不过,我们并不需要真正弄懂它在做什么,我们只消点击一下uVar1,Ghidra会自动帮我们跳转到左侧计算它对应的指令上,甚至连具体的内存地址都帮我们算好了。
勘误
这里 0x801a4e30
并不是 uVar1 的具体地址,这条指令只是uVar1的计算过程第一步。
0x801a4e30
应该是当前正在显示的文字的起始地址。后续的iVar10等等计算应该是用于计算当前需要渲染的文字相对于起始位置的偏移。
下面就是见证奇迹的时刻,让我们按耐住激动的心颤抖的手,去内存查看器里查看一下:
无需多言,这ABABCC的格式,正是当前游戏中显示的文本“やれやれ……”的格式。如果我们仔细研究一下,会发现这里的数字正是它们在显存文字页面里的序号!我们找到了文字的内存地址!
后续经过更多的测试,该处的文字地址形式为单个half-word(16bit)为一个字,18个half-word为一行,如果这一行不足18个字,则剩下的部分填入FFFF。如果当前没有文字显示,则该处内存全部为FFFF。至此,我们探明了《侦探神宫寺三郎-在灯火消逝之前》用于存储文字的内存地址。
请注意,这里我使用的是普及版的ROM,如果是原版的ROM,其地址会有所改变,具体地址为0x801a53d8
。
2025年8月27日更新:
这款游戏加载到内存的字库是会随着游戏进程改变的,所以当前显示的内容还需要根据当前加载的字库进行分析。这无疑为我们的目标增添了很大的难度,现在我需要把游戏给打一遍才能知道都有哪些字库,更别提还需要把这些字库里的文字图片识别成文字……