跳转至

RFZ 字形数量上限 (signed-16-bit localID) —— MSYH 字体崩溃根因

结论 (TL;DR)

RFZ 字体内嵌的 ChunkProcessorBinary 容器,其对象引用 localID 在反序列化时被读取/解释为有符号 16 位整数 (__int16)。 字形对象的 localID 从 base = 10002 起连续分配,因此:

最大可用 localID = 32767 (s16 正数上限)
字形数硬上限     = 32767 - 10002 + 1 = 22766 个字形
  • GASE 全部原版字体 (14/18/24/32/60pt) 字形数都恰好是 21720,最大 localID = 31721,刚好卡在上限下方 (余量仅 1046)。这不是巧合,而是被该格式上限约束。
  • 用户的 MSYH 字体有 29709 个字形,localID 最大到 39710,其中 6943 个字形的 localID > 32767
  • 第一个越界字形:序号 #22766,code = 34330 (U+861A),localID = 32768 → 作为 s16 = -32768 (负数)
  • 游戏反序列化读到这个"负 id"时,走进负 id 引用表分支,而该表为空 → 解引用空指针 → 崩溃

崩溃地址 0xE9381C,异常 0xC0000005 (访问违例),引用内存 0xC —— 正是 *(v10 + 12),其中 v10 == 0


动态调试崩溃信息 (IDA)

E9381C: The instruction at 0xE9381C referenced memory at 0xC.
        The memory could not be read (exc.code c0000005, tid 73092)
Debugger: thread has exited (code -1073741819)   ; 0xC0000005

崩溃函数链 (IDA, imagebase 0x400000)

1. ChunkProc_RestoreObjectFields @ 0xE919A0 —— schema 驱动的字段恢复器

处理 "object array field" (对象数组字段,如 Database._glyph) 时,逐元素读取 localID:

// 0xE91B85: 读数组长度 v19
// 循环 i = 0..v19:
(*(...+76))(restorer, &v21);          // 0xE91BA9: 读 1 个元素 localID 到 v21
                                      //          v21 是 __int16 (有符号 16 位!)
sub_42B3B4(this, a2, v11, i, v21);    // 0xE91BB8: -> ChunkProc_ResolveRefById(a5 = v21)

关键:v21 声明为 __int16,且 ChunkProc_ResolveRefById 的第 5 参 a5 也是 __int16。 存储为 16 位的 localID 一旦 ≥ 32768,按有符号解释即为负数。

2. ChunkProc_ResolveRefById @ 0xE93780 —— 按 localID 解析对象引用

_DWORD *__thiscall ChunkProc_ResolveRefById(_DWORD *this, int a2, int a3, int a4, __int16 a5)
{
  if ( a5 <= 0 )
  {
    if ( a5 >= 0 )                 // a5 == 0: 空引用,走默认处理
      return (*(...+60))(a3, a2, a4, 0);
    else                           // a5 <  0: 负 id 分支 (异常/外部引用表)
    {
      if ( this[16] )              // 0xE93808  this+64 = 负 id 表条目数
        v10 = this[14] - 16 * (a5 + 1);   // this+56 = 负 id 表基址, 步长 16
      else
        v10 = 0;                   // 0xE9381A  表为空 -> v10 = 0
      if ( *(_DWORD *)(v10 + 12) ) // 0xE9381C  *** 崩溃: v10==0 -> 读 [0xC] ***
        v11 = *(void **)(v10 + 4);
      else
        v11 = &unk_18F9D7B;
      sub_413C0A(v11, a2, a3, a4);
      return (*(...+60))(a3, a2, a4, 0);
    }
  }
  else                             // a5 > 0: 正常本地对象 id 分支
  {
    v5 = a5 - 10001;               // 0xE93797  base = 10001
    if ( v5 < 0 || v5 >= this[8] ) // this+32 = 本地对象表容量
      sub_4246DB(1, "ChunkProcessor : get-id(%d) is out of range.", a5 - 17);
    else { /* 查 this[7] (this+28) 对象表, 步长 12 */ }
    ...
  }
}
  • 正分支 (a5 > 0):localID - 10001 索引本地对象表 this[7](步长 12)。这是字形等本地对象的正常路径。
  • 负分支 (a5 < 0):索引 this[14] 负 id / 外部引用表(步长 16,计数 this[16])。字体流里此表为空 (this[16] == 0) → v10 = 0 → 解引用 0xC 崩溃。

字体流中本不应出现负 localID。MSYH 之所以出现,是因为正的 16 位 localID 数值超过 32767,被 __int16 读取后符号位翻转成了负数。


实测证据 (用已验证解析器 rfz_unpack.py 解压后统计字形 localID 数组)

字体 字形数 localID 范围 连续 >32767 的字形 判定
原版 32pt 21720 10002..31721 0 ✅ 可用
原版 60pt (20页) 21720 10002..31721 0 ✅ 可用
out_v7 12368 10002..22369 0 ✅ 不触发本崩溃
MSYH 29709 10002..39710 6943 崩溃
  • localID 从 base=10002 起严格连续localID(i) = 10002 + i
  • s16 上限 32767 → 字形数硬上限 = 22766
  • 首个越界:字形 #22766,code=34330 (U+861A),localID=32768 → s16 = −32768。

已排除的其它假设 (均有实测/IDA 依据)

假设 证据 结论
解压流过大 (76MB) 原版 60pt 解压 162MB 仍可用 排除
纹理页数过多 (9) 原版 60pt 20 页 仍可用 排除
AVTS 版本错误 各版本 == 页数+1;MSYH=10=9+1 符合 排除
字形 code 未排序 → 索引表越界 MSYH 严格升序、无重复 排除
code 索引表 u16 溢出 count=65487 < 65536 排除
DDS 格式 ARGB4444 2048²,与原版一致 排除
point 字段 (=32, 应为 24) 语义不规范但非崩溃直接原因 次要

修复方向 (说明,不修改打包脚本)

本崩溃是格式的内禀限制,非脚本 bug:RFZ 容器单个 Database 最多容纳 ≈ 22766 个字形对象(localID base=10002 + s16 上限 32767)。

要让 MSYH 字体能进游戏,必须把字形数裁到 ≤ 22766(建议留余量,对齐 SEGA 的 21720): - 删减低频/生僻 CJK 字形(MSYH 含 29709,需删约 7000)。 - 或按字符集 (如 GB2312 常用 6763 字 + ASCII + 常用符号) 重新生成 .fnt,使总字形数落在上限内。

注:游戏整套字号字体共用同一套字符集(都是 21720),SEGA 选择 21720 正是为贴近此上限。超出即崩。


相关 IDA 标注

  • 0xE93780 重命名为 ChunkProc_ResolveRefById,已在 0xE9381C / 0xE93797 加崩溃与正分支注释。
  • 0xE919A0 重命名为 ChunkProc_RestoreObjectFields,已在 0xE91BA9 标注"元素 localID 按 __int16 读取"。