起因

直接原因是我看到了今年 PSP Homebrew Developer Conference 上 Precise Museum 做的报告,其中提到他们刚刚发布的《噗哟噗哟20周年纪念版》PSP版本的英化补丁在制作过程中遇到的一个问题:如果一个字库文件中的非全宽字符(即宽度不等于文件头中描述的最大宽度的字符)出现在字库的第一行以外的行,就会使这个字符无法正常显示。他们后来通过缩小字体的大小、重新排列字符使非全宽字符全都出现在第一行绕开了这个问题。

他们提到这件事引起了我的兴趣,因为我在 5 年前开始研究这个游戏的 Wii 版本的汉化的时候就遇到过这个问题,不过中文字符都是方形,简单重排一下字库文件,把少数的非方形字符放到前面很容易解决这个问题。不过后来噗哟群没找到人翻译,汉化版的工作就搁置了。

我电脑上正好已经装了 Ghidra 以及用于解析 Wii 可执行文件格式的插件,于是决定试试找出这个问题的根源。我之前的逆向经验仅有两个毫无难度的 Unity 游戏(一点混淆没有,符号表全在游戏里),因此这次稍微攒点逆向经验(真能攒到吗)。

背景:关于文件格式

这里需要先介绍一下这个游戏使用的字库和文本的文件格式。英文社区已经有人写了用于编辑这个游戏的大部分文件的工具(Puyo toolsPuyo text editor),从这些工具的源码中可以了解到文件格式的信息。下面我借用上文提到的报告视频中的示意图来介绍。

fnt file structure

上图展示的是字库文件,即 FNT 文件的格式。文件头中给出了字库中所含字符的尺寸和个数,随后以列表的形式给出了每个字符的 UTF-16 编码和显示宽度。字符表后面直接接一个材质(贴图)文件(PSP版中为 GIM, Wii版中为 GVR)。值得注意的是,虽然字符表中规定了每个字符的宽度,但是这个宽度只用于显示字符串时确定下一个字的水平位置。在材质文件中,字符仍然以文件头中的尺寸整齐地排列。

每个 FNT 文件会配套一个 MTX 文件,里面存储游戏中用到的各种字符串。每个字符占 2 个字节,表示这个字符在 FNT 文件中的 index 。比如在图中的字库中,的编码就是0000

正文

准备工作

为了能解析 Wii 的可执行文件格式(dol),需要给 Ghidra 安装相应的插件。插件可以在此处下载。随后将游戏镜像中的可执行文件 main.dol 提取出来(Dolphin 模拟器就可以),加载进 Ghidra 里就可以开始分析了。

定位相关逻辑

可执行文件里显然没有任何符号表和调试信息。想要直接找到处理 FNT 文件的函数就像是大海捞针。十分幸运地是,通过在程序中搜索FNT字符串,我发现了可执行文件中“嵌入”了一对FNT文件和MTX文件,用于在游戏刚启动时显示创建存档等提示信息和错误信息。

embedded fnt file

直接跟随这里的XREF可以见到这样的函数(函数名是我改的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
undefined4 * load_embed_text(undefined4 *param_1)
{
uint size;
uint size_00;
undefined *puVar1;

*param_1 = 0;
param_1[4] = 0;
param_1[5] = 0;
param_1[6] = 0;
param_1[7] = 0;
param_1[8] = 0;
param_1[9] = 3;
param_1[10] = 0;
param_1[0xb] = 0;
param_1[0xc] = 0;
param_1[0xd] = 0xffffffff;
*(undefined2 *)(param_1 + 0xe) = 0;
puVar1 = malloc_simple(0x8c);
if (puVar1 != (undefined *)0x0) {
FUN_800305e4(puVar1,0,1);
}
size_00 = x402c8;
*param_1 = puVar1;
puVar1 = malloc(size_00,0x20,0x20,0);
size = DAT_803606ec;
param_1[4] = puVar1;
puVar1 = malloc(size,0x20,0x20,0);
param_1[5] = puVar1;
memmove((undefined8 *)param_1[4],(undefined8 *)ptr2fnt,size_00);
memmove((undefined8 *)param_1[5],(undefined8 *)ptr2mtx,size);
puVar1 = malloc_simple(0x34);
if (puVar1 != (undefined *)0x0) {
FUN_800315d8(puVar1,(undefined *)*param_1,(undefined *)param_1[4],size_00,1);
}
FUN_800308e4(*param_1,puVar1,0);
puVar1 = malloc_simple(0x28);
if (puVar1 != (undefined *)0x0) {
FUN_80031abc(puVar1,*param_1,param_1[5],size);
}
FUN_80030a54(*param_1,puVar1);
FUN_80030a5c(*param_1,0);
memset_((int)(param_1 + 1),0,0xc);
puVar1 = malloc_simple(0x50);
if (puVar1 != (undefined *)0x0) {
FUN_8001d3ac(puVar1,*param_1,DAT_802a2810);
}
param_1[1] = puVar1;
*(undefined4 *)(puVar1 + 0x18) = 0;
*(undefined4 *)(puVar1 + 0x1c) = 0;
*(undefined *)(param_1[1] + 0x11) = 3;
*(byte *)(param_1[1] + 0x10) = *(byte *)(param_1[1] + 0x10) | 1;
FUN_8002f8cc(param_1,param_1[7]);
return param_1;
}

注:这里的malloc memmove memset等函数并不是原来就叫这些名字,而是根据函数体内容和用法作出的推测(意外的是 LLM 能轻松地识别出这些经过优化过后的函数的功能)

这里注意到程序在把 FNT 文件复制到新分配的内存里之后对其调用了 FUN_800315d8

1
2
3
4
5
6
7
8
9
10
11
12
int FUN_800315d8(undefined *param_1,undefined *param_2,undefined *param_3,uint size,int param_5)
{
list_push_front((ListNode *)param_1,param_2);
*(undefined **)(param_1 + 8) = &DAT_801a2944;
*(undefined4 *)(param_1 + 0x20) = 0;
*(undefined4 *)(param_1 + 0x24) = 0;
*(undefined4 *)(param_1 + 0x28) = 0;
*(undefined **)(param_1 + 0x2c) = param_2;
param_1[0x30] = 0;
parse_fnt((uint *)param_1,(uint *)param_3,size,param_5);
return (int)param_1;
}

这里面调用的 parse_fnt 是实际用于解析FNT文件的函数,内容相当长,下面贴出一部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void parse_fnt(uint *param_1,uint *fnt_base,int param_3,int param_4)
{
...
param_1[4] = (uint)fnt_base;
if (fnt_base == (uint *)0x0) {
param_1[5] = 0;
param_1[6] = 0;
param_1[7] = 0;
return;
}
uVar11 = fnt_base[1];
fnt_base[1] = uVar11 << 0x18 | (uVar11 & 0xff00) << 8 | uVar11 >> 0x18 | uVar11 >> 8 & 0xff00;
uVar11 = *(uint *)(param_1[4] + 8);
*(uint *)(param_1[4] + 8) =
uVar11 << 0x18 | (uVar11 & 0xff00) << 8 | uVar11 >> 0x18 | uVar11 >> 8 & 0xff00;
uVar11 = *(uint *)(param_1[4] + 0xc);
*(uint *)(param_1[4] + 0xc) =
uVar11 << 0x18 | (uVar11 & 0xff00) << 8 | uVar11 >> 0x18 | uVar11 >> 8 & 0xff00;
param_1[5] = 0;
uVar11 = param_1[4] + 0x10;
param_1[6] = uVar11;
param_1[7] = 4;
param_1[8] = uVar11 + *(int *)(param_1[4] + 0xc) * 4;
for (uVar11 = 0; uVar11 < *(uint *)(param_1[4] + 0xc); uVar11 = uVar11 + 1) {
puVar10 = (ushort *)(param_1[6] + param_1[7] * uVar11);
uVar2 = *(ushort *)(param_1[6] + param_1[7] * uVar11);
*puVar10 = uVar2 >> 8 | uVar2 << 8;
puVar10[1] = puVar10[1] >> 8 | puVar10[1] << 8;
}
...

注意到这里只是将 FNT 文件中的各个字段从小端序转换到 PowerPC 架构使用的大端序。另外,这个 parse_fnt 函数被两个不同的函数调用,说明加载游戏中其他字库时大概率也会调用这个函数。

考虑到可执行文件中嵌入的那段文本只有在新存档才会显示一次,对调试来说并不方便,我想要换成使用游戏主菜单对应的文本文件进行调试。因此我在 Dolphin 模拟器中对 parse_fnt 函数打断点,跳过了加载嵌入文本的断点,在进入主菜单瞬间成功触发断点。

触发断点之后就可以获得加载进来的 FNT 文件的内存位置。

fnt loaded in memory

parse_fnt 函数中似乎并没有切分图片中的字符的逻辑,因此我对 FNT 文件中的字符表打内存断点,看游戏什么时候会读取它们。

果然,几帧之后触发了内存断点,并且屏幕上播放文字时每出现一个字就会调用一次这个函数(在反编译结果上有一些小修改):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void FUN_80030318(int *param_1,undefined2 param_2,uint charindex,undefined4 param_4,
undefined4 param_5,undefined4 param_6,undefined4 param_7)
{
uint uVar1;
int iVar2;
int *piVar3;
undefined2 *puVar4;
uint charcnt_perline;
int *iVar7;
undefined4 local_38;
undefined4 local_34;

iVar7 = (int *)((int *)param_1[2])[4];
iVar2 = FUN_80031784((int *)param_1[2],charindex);
piVar3 = (int *)FUN_8001b238(param_5);
(**(code **)(*piVar3 + 0x10))
(piVar3,4,*(undefined4 *)(param_1[2] + 0x28),3,iVar7[2] & 0xffff,iVar7[1] & 0xffff);
uVar1 = countLeadingZeros((uint)*(byte *)((int)param_1 + 0x12));
(**(code **)(*piVar3 + 0x1c))(piVar3,param_4,uVar1 >> 5);
piVar3 = (int *)FUN_80024ba8(param_1[1],piVar3);
local_38 = param_6;
local_34 = param_7;
(**(code **)(piVar3[2] + 0x18))(piVar3,&local_38);
*(undefined *)((int)piVar3 + 0x11) = *(undefined *)(param_1 + 4);
charcnt_perline = 0x200 / (*(ushort *)(iVar2 + 2) + 1); //!!!!!
FUN_80028c68(piVar3,(iVar7[2] + 1) * (charindex - (charindex / charcnt_perline) * charcnt_perline)
* 0x1000,(iVar7[1] + 2) * (charindex / charcnt_perline) * 0x1000);
puVar4 = (undefined2 *)malloc_simple(0xc);
puVar4[1] = *(undefined2 *)(iVar2 + 2);
*puVar4 = param_2;
*(int *)(puVar4 + 4) = param_1[3];
*(int **)(puVar4 + 2) = piVar3;
param_1[3] = (int)puVar4;
return;
}

通过查看寄存器和内存可以得知,iVar7指向的正是 FNT 文件的内容,在接近结尾的地方有一个非常可疑的 charcnt_perline = 0x200 / (*(ushort *)(iVar2 + 2) + 1) ,而正是这一行触发了内存断点,读取了某个字符的宽度值,用于计算字库中每行字符的数量

通过多次触发断点,我发现了这个函数的第三个参数就是将要显示的字符在字库里的序号,将其命名为 charindexiVar7[1]iVar7[2] 分别是 FNT 文件中定义的单元格高度和宽度。可以发现,在触发断点的代码下一行,程序计算了将要从字库的哪个位置切出字符的显示图形,其中 (iVar7[2] + 1) * (charindex - (charindex / charcnt_perline) * charcnt_perline) 是水平位置,(iVar7[1] + 2) * (charindex / charcnt_perline) * 0x1000) 是垂直位置。

那么问题的原因就很明显了:由于程序员的疏忽,在计算每行字符数量的时候使用了每个字符的宽度,而不是实际上的文件头中规定的宽度,算出的每行字符数是错的,导致后面计算字符位置也算错了。第一行的非全宽字符逃过一劫,因为这些字符的 (charindex / charcnt_perline) 一定为0。

解决问题

找到问题的根源之后解决起来就简单了,只需要将 charcnt_perline = 0x200 / (*(ushort *)(iVar2 + 2) + 1) 改成 charcnt_perline = 0x200 / (iVar7[2] + 1) 即可。具体地,仅需将 0x800303f8 处的一条指令修改为 80 9a 00 08 即可修复此问题。手动用十六进制编辑器找到对应指令然后直接改就行了。

我制作了测试用的文本和字库(下图1),导入到游戏里,启动游戏以验证问题的解决(下图2)。

fnt file used

result comparison

可以看到能正常显示第二行的英文字母了。有些字母下面被切了是因为我写的用来生成字库用的脚本对字符尺寸的处理有点问题。

总结 & 闲话

虽然我最后解决了 Wii 版上的这个问题,但是好像价值也不是很大,因为英文版那边已经有 workaround 了,汉化版这边甚至不用 workaround 虽然缺翻译导致汉化版可能会无限拖下去,这个问题属于是小问题。另外 PSP 版因为没有在可执行文件里嵌入字库,所以定位问题可能会更难,不过有 Wii 版的经验,应该能想办法找到类似结构的函数(?)

这应该是我第一次尝试逆向这种陌生架构的程序,意外地还挺顺利的。不过要是遇到混淆过的程序那可能就两眼一黑了。

留言

2025-08-25

⬆︎TOP