2025.4.13

《超级碧姬公主》汉化笔记(二):字库扩容

给字库做手术。

《超级碧姬公主》汉化笔记(二):字库扩容
《超级碧姬公主》汉化笔记(二):字库扩容

« 《超级碧姬公主》汉化笔记(一):文本和字库分析

上回书说到,这个游戏原本的字库只保存了448个字符。接下来的任务,就是想办法给字库来进行扩容。

字库提取

在扩容之前,首先得想办法把原有的字库提取出来。尽管我在上篇文章中已经用CrystalTile2读取出来了arm9.bin中保存的字库,然而字符数据和图片数据的对应关系还是需要手动写脚本来处理。

根据Shift-JIS的编码方式,编写一个脚本来遍历所有的字符。从CrystalTile2上可以读出字库里最后一个字符是,Shift-JIS编码是0x97CE,说明只要处理高字节在0x81-0x97范围内的字符即可。如何判断一个字符是否能被正确编码呢?最科学的办法应该是去读编码规范,判断高字节和低字节是否在正确的范围内。但是有个偷懒的办法,直接让Python解码字节数据,如果不报错说明能够正确编码。代码:

for hi in range(0x81, 0xA0):
  for lo in range(0x40, 0xFF):
    code = hi * 0x100 + lo
    try:
      char = struct.pack(">H", code).decode("shift-jis")
    except UnicodeDecodeError:
      continue

然后还需要根据上篇文章分析出来的字符编号计算方式,将Shift-JIS编码转变为字符编号:

def code_to_index(code: int):
  hi = (code >> 8) & 0xFF
  lo = code & 0xFF
  return (hi & 0x3F) * 0xC0 + (lo - 0x40)

offset_offset = code_to_index(code) * 4 + 0xEE3A8

之后根据编号查找到字符数据的地址:

(data_offset,) = struct.unpack("<I", data[offset_offset : offset_offset + 0x04])
data_offset += 0xEE3A4

(unk_value,) = struct.unpack("<H", data[data_offset : data_offset + 0x02])
assert unk_value == 0x0208
char_data = data[data_offset + 0x02 : data_offset + 0x22]

接下来需要知道二进制数据如何转换成图片数据。上篇文章提到了“每个像素的位深度是2bpp”,也就是说每个像素用2位来表示颜色——这个是怎么算出来的呢?

众所周知,一个字节的数据范围是0-255,共有256个数字。因为256 = 28,所以这个范围用二进制表示就是0b0000 0000-0b1111 1111,即1字节=8位(1 byte = 8 bit)。但在字符显示的时候,每个像素点的颜色不需要有256种,最简单的情况下,只需要2种,即“黑色”和“白色”。在这种情况下,一个字节可以存放8个像素点,但是代价就是没有抗锯齿效果,只能显示像素字。而在这个游戏里,根据我们的计算,一个字符共有8 x 16 = 128个像素点,而存储区域是32个字节(256位),因此每个像素点的位深度是256 / 128 = 2bpp。这说明,每个像素点可以有4种颜色,即黑色、白色和2种深度的灰色。

接下来需要将字节数据转换为图片数据。首先需要遍历每个字节,然后将1个字节拆成4个2位的数字,每个数字对应一个像素点的颜色。这里其实涉及到了按位操作以及大端序、小端序的问题,一般情况下数据都是按小端序保存的,即存放地址越靠前的数据所在的位越低。其实也很好排查,如果提取出来的图片呈现锯齿状,一般就是端序搞错了。这里用到的是Python的PIL库,代码如下:

colors = (0xFF, 0xFF, 0xFF, 0xAA, 0xAA, 0xAA, 0x55, 0x55, 0x55, 0x00, 0x00, 0x00)
pixels = bytearray()
for byte in character.data:
  pixels.extend([byte & 0b11, (byte >> 2) & 0b11, (byte >> 4) & 0b11, (byte >> 6) & 0b11])
tile = Image.frombytes("P", (8, 16), bytes(pixels))
tile.putpalette(colors)

通过遍历字符→获取字符数据→转换成图片数据的操作,字库就被提取出来了。

从游戏中提取出来的字库
从游戏中提取出来的字库

编码方式

既然提取字库的方法已经分析出来了,接下来就需要生成字库了。由于翻译后的文本是简体中文,而很多简体中文汉字是没有Shift-JIS编码的,为了让汉字能够正常显示,需要自己手动处理编码。自定义编码的汉化方式对于一些老旧的游戏来说还是很常见的,稍微现代一点的游戏一般都是UTF-8编码或者UTF-16-LE编码了。

对于自定义的编码方式,不同人的选择不同。基本上有这么几种处理方式:

  1. 按字符在文本中的出现顺序排序,先出现的在最前面。
  2. 按字符在文本中出现的频率排序,出现次数多的在最前面。如果字库的数量有限制,可以考虑只保留出现频率最高的字符,这样可以让文本受到的影响最小(当然,最好还是能想办法扩充字库)。
  3. 按字符的Unicode编码排序,最常见的初始序列就是“一丁七万丈三上下不与”。
  4. 按字符的拼音排序,GB2312编码就是这么排的。顺带一提,我汉化《数码宝贝物语 遗失的进化》时也用到了拼音顺序,因为要实现数码宝贝名字的拼音排序,直接把汉字的拼音按顺序编码是最简单的。
  5. 按日文汉字与简体中文汉字的对应关系一一替换,剩下的汉字按上面提到的方法排序。用这种方法编码的比较少,我汉化《世界树的迷宫III 星海的访客》是用的这个方法,好处是如果拿文本编辑器直接打开含有文本的文件,可以在没有编码表的情况下大致读懂文本的含义。

由于这个游戏的字库大小很紧凑,所以我首先想到的是按字符在文本中出现的频率排序。具体的字符替换方式应该比较简单,这里就不贴代码了。

字库生成

搞定了编码方式之后,接下来需要按照编码来生成单个字符。仍然用到PIL库,代码如下:

colors = (0xFF, 0xFF, 0xFF, 0xAA, 0xAA, 0xAA, 0x55, 0x55, 0x55, 0x00, 0x00, 0x00)
palette = Image.new("P", (8, 8))
palette.putpalette(colors)
muzai = ImageFont.truetype("files/fonts/MZPXorig.ttf", 12)

def draw_with_muzai_pixel(char: str) -> bytes:
  tile = Image.new("RGB", (8, 16), (0xFF, 0xFF, 0xFF))
  draw = ImageDraw.Draw(tile)
  draw.text((0, 14), f"{char}  黑鼠龙龟", (0x00, 0x00, 0x00), muzai, "ls")
  tile = tile.quantize(palette=palette, dither=Image.Dither.NONE)

  tile_data = tile.tobytes()
  new_bytes = bytearray()
  for i in range(0, len(tile_data), 4):
    byte = tile_data[i] | (tile_data[i + 1] << 2) | (tile_data[i + 2] << 4) | (tile_data[i + 3] << 6)
    new_bytes.append(byte)

  return bytes(new_bytes)

这里像素字体用的是目哉像素,为数不多可选的8x12像素字体(关于中文像素字体的选取,再次建议参考星夜之幻前辈写的文章“小点阵字体速览”)。8x16的像素字体我没有找到,如果有人了解的话可以推荐一下。为什么要在字符后面加个“  黑鼠龙龟”呢?因为有些字体的基线设置有些问题,导致字符的高度不一致,可能会导致字符在字库中显示错位。加上这几个字可以让字符的高度一致,避免错位的问题。

空间压缩

到前面为止的流程都还算比较通用,是所有汉化游戏的必经之路。但是接下来的问题就是,如何把生成的字库放到游戏中?

之前不知道在哪里看过,通常来说,汉化一个游戏所用的字符种类大概在2000-3000种左右,文本量较小的游戏可能会更少。通过将文本提取出来后快速机翻一遍可以大概确定文本量,这里我确定了汉化这个游戏需要用到的字符大概在1300字左右。前面提到这个游戏原本的字库只保存了448个字符,所以下面要开始分析如何让这块区域尽可能塞更多字符进去。

在原本的游戏中,arm9.bin中保存字库的区域是0xEE3A8-0xFDF28,共0xFB80个字节,其区域分配是这样的:

相对起始地址的偏移量 大小 含义
0x0000-0xC000 0xC000 偏移量数据,每个字符需要4个字节,一共0x3000个字符
0xC000-0xFB80 0x3B80 像素数据,每个字符需要34个字节,一共448个字符

为什么偏移量数据是0x3000(12288)个字符呢?还是看Shift-JIS的编码规则,对于双字节编码,高字节的范围是0x81-0x9F0xE0-0xEF,在对0x3F做按位与之后的数值范围是0x01-0x1F0x20-0x2F,于是理论上最大的字符编号是0x2F * 0xC0 + 0xFC - 0x40 = 0x23FC,所以0x3000个字符的空间绰绰有余。这里我猜测是开发人员把高字节的范围当成了0xE0-0xFF,或者是把高字节按位与之后直接乘了0x100,导致算出来了0x3000这个数。

实际上,游戏中根本用不到这么多字符。在导入时,可以按从小到大的顺序依次分配编码,只把用到的编码导入到游戏中,然后在偏移量数据后面紧跟着像素数据,这样就可以最大限度地分配空间了。此外,Shift-JIS字符实际上是不连续的。从编码表里可以看出来,0x84BF-0x889E的范围是没有编码任何字符的。另外,低字节中的0x7F0xFD-0xFF也是没有被使用的。尽管可以忽视这些限制(因为游戏里通常不会对字符编码做严格的校验),但是为了避免出现意外的错误,还是要尽量遵循这些限制,不要用这些位置作为编码。但同时,在偏移量数据里这些位置也永远不会被读取,可以被用来插入像素数据。所以,最后得到的字库区域分配如下:

相对起始地址的偏移量 大小 含义
0x0000-0x0300 0x0300 22个像素数据(替换0x8040-0x80FF
0x0300-0x076C 0x046C 283个偏移量数据( -z0x8140-8x829A
0x076C-0x197C 0x1210 136个像素数据(替换0x829B-0x889E
0x197C-0x2AFC 0x1180 1120个偏移量数据(亜-止0x889F-0x8E7E
0x2AFC-0xBE24 0x9328 1108个像素数据

细心的话可以发现像素数据只有1266个,但是偏移量数据却有1403个,这正是因为一些编码被跳过了。经过这番处理,还有0xFB80 - 0xBE24 = 0x3D5C个字节的空余空间,大概还可以放400个字左右,因为机翻和人工翻译的字符总数相差不会特别大,基本是够用了。

新生成的字库(部分)
新生成的字库(部分)
显示效果
显示效果

万一不够用怎么办?

虽然目前来看是够用了,但是万一还是不够用怎么办呢?这里提供几个可行的思路:

  1. 忽视Shift-JIS的编码规范,高字节从0x80开始编码,低字节采用0x40-0xFF编码,减少偏移量数据中的空白。或者也可以遵循编码规范,但是修改计算方式,让字符编码在经过转换后连续。
  2. 去掉像素数据最前面的0x08 0x02字节序列(因为这个序列对所有字符都是一致的),并修改相应的程序,使这两个值直接作为常量编码到程序中。
  3. 去掉所有偏移量数据,直接根据字符编码计算像素数据的偏移量。
  4. arm9.bin扩容,并修改调用字库的地址。

这几个办法能使字库容量进一步扩充,但是工作量也会相应地变大,可以留作课后习题。

接下来呢?

文本和字库都解决了,接下来该处理图片了。然而,图片又是一个让人头疼的问题,且听下回分解。

《超级碧姬公主》汉化笔记(三):图片导出 »

如果需要在留言中发布图片,请前往GitHub上的Discussions。您也可以通过bilibili的私信功能与我联系。