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 读取"。