RFZ 字体打包规范
Warning
此文档可能需要更新
本文档描述
font/rfz_pack.py—— RFZ 字体的重打包器, 是font/rfz_unpack.py(见rfz_unpack_spec.md) 的逆操作。给定解包产物 (metadata.json / glyphs.csv / page*.dds + decompressed.bin 模板), 重建可被游戏正确加载的 RFZ 文件。验证结论: 对 14pt 与 240pt 两个字号,
unpack(pack(unpack(原始)))的 decompressed.bin / 全部 DDS / glyphs.csv / metadata.json 逐字节一致。
0. 打包流水线总览
解包产物目录 (rfz_unpack.py 输出)
├─ metadata.json ─┐
├─ glyphs.csv ├─[1] 重建"数据承载区"
├─ page0.dds, page1.dds ─┘
└─ decompressed.bin ──────[1] 提供"框架模板"(HEAD/schema/纹理段头/尾部)
│
└─[1] 拼接成完整 ChunkProcessorBinary 流 + 回填 YABX payload_size
└─[2] LzwEncoder 压缩 (镜像 YbLzwDecoder 的字典状态机)
└─[3] 加 4 字节头部 "YS"+0x02+0x00
└─ output.rfz
唯一的"难点"是第 [2] 步的 LZW 编码器 (解码器的对偶)。本文档重点解决它, 并说明 [1] 步"数据区重建 vs 框架模板复制"的边界划分与取舍。
1. 解压流的段结构 (打包时的拼接蓝图)
打包 = 把下列段按序拼接成解压流, 再压缩。各段边界靠锚点动态探测
(见 locate_segments), 不硬编码偏移, 因此与字号无关。以 14pt 为例的实测边界:
| # | 段 | 范围 (14pt) | 来源 | 重建依据 |
|---|---|---|---|---|
| A | HEAD: YABX 头 + svo 前导 + 完整 schema | 0x0..0x255 |
模板复制 | 自描述 schema 语法未逐字节逆向 |
| B | Database 实例前缀 (id..glyph_cnt) | 0x255..0x2e1 |
重建 | metadata.json |
| C | glyph 字段体 (localID 数组) | 0x2e1..0xac99 |
重建 | localID[i]=0x2712+i |
| D | 字形对象表头 (10B) | 0xac99..0xaca3 |
重建 | 06000000 01000000 <u16 last_localid+1> |
| E | 字形定义区 (count×36B) | 0xaca3..0xc9b03 |
重建 | glyphs.csv |
| F | 纹理段头 (TextureResource schema+实例) | 0xc9b03..0xcaf8d (5258B) |
模板复制 | 含 DDS 名/尺寸/像素格式, 见 §4 |
| G | DDS 像素数据 (拼接各页) | 0xcaf8d..0xccb08d |
重建 | page0.dds + page1.dds + ... |
| H | 尾部 (全零) | 0xccb08d..end (19B) |
模板复制 | 用途未明, 恒为 0 |
拼接后回填 YABX payload_size (偏移 0x08) = 总长 − 16; hash (偏移 0x0c)
沿用模板值 (见 §5)。
1.1 数据承载区重建细节 (B/C/D/E/G)
- B 实例前缀: 5 个字符串字段 (id/platform/library/name/comment) 按 TLV
<u32 size><u16 strlen+1><bytes\0>编码, 后接 flags(u32) + 5×u16 + tex_page(u32) - 5×u16 (见
rfz_unpack_spec.md §3.3)。 - C localID 数组:
<u32 size><u32 count><count×u16>, localID[i] =0x2712 + i。 - D 字形表头: 固定
06 00 00 00 01 00 00 00+ u16(0x2712 + count)。 - E 字形定义: 每条
<u16 marker=3><u32 body=30><body>, body = code(u16) + 10×u16(GLYPH_FIELDS) + kerning(04000000 00000000)。 - G DDS: 直接把 page0.dds、page1.dds…… 按序拼接 (每张含完整 128B DDS 头)。
2. LZW 编码器 (核心, 解码器的严格对偶)
LzwEncoder 镜像 LzwDecoder (见 rfz_unpack_spec.md §2) 的同一字典状态机:
同样的加表时机、GIF 式早切码宽增长、next_code==4094 满表重置、重置后首码字不加表。
因为两者操作 1:1 对应, 锁步由构造保证。编码器独有的只有"选码字"逻辑 = 贪心最长匹配。
2.1 贪心最长匹配
i = 0; first = True; skip_add = False
while i < n:
code = data[i]; j = i+1 # 从单字节起匹配
while j < n and (code, data[j]) in fwd: # 沿已存在字典项尽量延长
code = fwd[(code, data[j])]; j += 1
fb = data[i] # 匹配串首字节 = root(code)
emit(code) # 按当前 code_bits 输出
if not first and not skip_add:
skip_add = add_or_reset(fb) # 与解码器同一加表/重置
else:
skip_add = False
prev_code = code; first = False; i = j
- 字典
fwd[(prefix_code, byte)] -> code, 与解码器的反向链字典{last,nxt}对偶: 解码器加表nxt[next_code]=prev_code; last[next_code]=fb等价于fwd[(prev_code, fb)] = next_code。 - 首码字与重置后首码字不加表 (
first/skip_add门控), 完全对应解码器。
2.2 关于不实现 KwKwK
贪心匹配只使用已存在字典项 (码字恒 < next_code), 因此本编码器从不产生
等于解码器 next_code 的码字 → 解码端永远走"普通码字"分支, 不触发 KwKwK。
这完全合法 (标准 LZW 的子集), 代价是产物比 SEGA 原始 RFZ 略大 (见 §6 实测 +1.2%),
但解码后逐字节一致, 不影响游戏加载。
2.3 ★ MSB-first 位写入器 + 一个关键性能陷阱 ★
位写入必须与解码器的大端读取对偶:
def _emit(self, code):
self.acc = (self.acc << self.code_bits) | code
self.nbits += self.code_bits
while self.nbits >= 8:
self.nbits -= 8
self.out.append((self.acc >> self.nbits) & 0xFF)
self.acc &= (1 << self.nbits) - 1 # ★ 必须收窄 ★
★ 踩坑记录 (O(n²) 性能 bug) ★: 最初实现漏掉了最后一行的
acc收窄。 后果:acc每次_emit左移code_bits位却从不丢弃高位 → 累积成数百万位的 大整数 → Python 每次<</>>变 O(acc 位数) → 整体退化为 O(n²)。 表现: 240pt(8.4MB, ~150K 码字) 尚能 7.9s 跑完, 但 14pt(13.4MB, ~2.5M 码字) 在 180s 内跑不完。加上收窄行后, 14pt 编码 180s+ → 2.7s。 教训: 位累加器务必在每轮输出后掩码回nbits位 (解码器_read本就如此, 编码器对偶亦然)。
末尾 _flush 把不足 8 位的残余左移补零成最后一字节; 解码器在输入耗尽时
(残余位数 < code_bits) 自然停止, 不会误读多余码字。
3. 边界探测 (与字号无关)
locate_segments(tmpl) 不硬编码偏移, 而是靠锚点:
1. RHFONTDB 实例锚点 0b 00 00 00 09 00 "RHFONTDB\0" → 段 B 起点。
2. 复用 parse_metadata 得到 glyph 字段偏移 → 段 C; C+4+size → 段 C 终点。
3. 搜 03 00 1e 00 00 00 (marker=3,body=30) → 段 E 起点; 按 glyph_cnt 遍历
(每条 6+body) → 段 E 终点。
4. 从段 E 终点搜 "DDS " → 段 F/G 分界。
5. 按 DDS 头 (dwSize==124 + linearSize) 累加各页 → 段 G 终点 = 段 H 起点。
因此同一份 rfz_pack.py 可处理全部 6 个字号, 无需改偏移。
4. 关于内嵌 DDS 名字 (回答"为啥不用那个名字")
解压流里确实内嵌了纹理文件名。240pt 实测 (decompressed.bin 内):
0x16ed "__HmfToSvo__RFO_SEGAKAKUGOTHIC_DB_240pt_0000.dds" (chunk 文件名)
0x206d "RFO_SEGAKAKUGOTHIC_DB_240pt_0000.dds" (资源文件名)
0x20dd "RFO_SEGAKAKUGOTHIC_DB_240pt_0000" (资源名, 无扩展名)
_0000 = 页索引 (page N → _NNNN)。
为什么打包器没把它当输入用:
1. 这些名字串全部落在段 F (纹理段头) 内, 而段 F 被打包器整段从模板原样复制,
名字因此自动保留正确, 无需 page.dds 文件名携带任何信息。
2. LZW 是字节流压缩, 与文件名无关 —— 名字只是被压缩的普通数据, 对编码算法无特殊作用。
3. 打包器只关心 DDS 的页序* (page0→page1→…), 名字里的 _0000/_0001 顺序与此一致,
故按索引拼接即可, 不必解析名字。
换言之: 名字"用得上"的猜测方向对, 但它们已被框架模板兜住了。 若将来要改页数/改字体名(模板不再适用), 才需要解析并重生成这些名字串 —— 那属于"完整重写 schema 框架"的范畴 (见 §6 取舍)。 可选改进: 解包时把内嵌纹理名记入 metadata.json, 使产物更自描述 (当前未做)。
5. YABX 头部回填
payload_size(偏移 0x08) = 拼接后总长 − 16, 必须按新流长度回填 (本器已做)。hash(偏移 0x0c): 自定义算法, 写入时回填、加载时不校验 (IDA 复核sub_E91400仅校验 magic, 见rfz_unpack_spec.md方法论)。 故打包器沿用模板的 hash 值。由于内容若被修改、hash 会失配, 但游戏不校验 → 不影响加载。
6. 设计取舍
| 取舍 | 说明 | 影响 |
|---|---|---|
| 需要 decompressed.bin 作模板 | 段 A/F/H (schema 框架/纹理段头/尾部) 随字号变化 (含字体名/尺寸/文件名), 其自描述语法与自定义 hash 未逐字节逆向 | 只能改"数据区"(度量/像素), 不能凭空换字号/字体名 |
| 产物非字节一致 | 编码器不实现 KwKwK 微优化 | 比原始 RFZ 略大 (14pt +1.2%, 240pt +10.6%); 但解码后逐字节一致, 游戏可加载 |
| hash 不重算 | 加载不校验 | 无 |
结论: 在"修改字形度量 / 替换纹理像素"这类保持结构的重打包场景下, 本器产物与游戏期望逐字节等价 (经同一已验证解码器解出)。 "完全脱离模板、从零生成任意字号"则需进一步逆向 ChunkProcessor schema 写入器与 hash 算法。
6.1 关于 bmfont→RFZ 全新字体 (2026-06-14: 缺口已关闭)
更新: 模板无关的全新字体打包已打通并逐字节验证, 详见
bmfont_to_rfz_spec.md。 工具font/bmfont_to_rfz.py仅凭 bmfont.fnt+ 多页 DDS 即可程序化重建整条 RFZ, 不再需要任何 decompressed.bin 模板。本节保留原分析作背景。
字形坐标语义已打通 (见 rfz_glyph_coords_analysis.md): 文件 box_x1/y1/x2/y2 = DDS 图集像素矩形
(非源坐标), 故 bmfont .fnt 字段可直接映射:
| bmfont char | → RFZ glyph |
|---|---|
| x, y | box_x1, box_y1 |
| x+width, y+height | box_x2, box_y2 |
| xadvance | cell_inc_x |
| xoffset, yoffset | origin_x, origin_y (s16, 见下) |
| page | page |
| id | code (>0xFFFF 跳过) |
关闭缺口的三项关键发现:
1. 段 A (schema) 字体无关: 522B SCHEMA_CONST 在 14pt/240pt 间逐字节一致, 可内嵌为常量;
实例头 obj_count=glyph_cnt+2、Database body_size=len(段B+C+D) 均可计算。
2. 外层 hash = CRC-32/BZIP2(payload), 含 19 字节段H 全零尾 (计入 payload_size 与 CRC), 可正确重算。
3. 纹理段 F/G 由 font_svo_pack.py 按任意页数/字体名生成 (AVTS 目录 + 内层 YABX), 外裹
TextureResource 帧 u16(4)+u32(4+svo_len)+u32(svo_len)+svo。
重要类型修正 (IDA 复核): origin_x/origin_y 在游戏 schema 中注册为 s16
(sub_F270C0/sub_F270F0 → sub_43969E("s16")), 其余字形字段为 u16。bmfont 的负偏移须按
有符号保留; SEGA 空格字形的 0xFFFF 哨兵按 s16 即 -1。本仓三个工具已统一 GLYPH_SIGNED。
像素格式无需转换: 实测 bmfont DDS 与 RFZ 内嵌均为 2048² ARGB4444 (masks f00/f0/f/f000)。 注意 DDS 头
dwPitchOrLinearSize: SEGA 用 LINEARSIZE(整块), bmfont 用 PITCH(每行), 切块需按 dwFlags 区分。
7. 实测验证 (2026-06-10)
测试: 解包原始 → rfz_pack.py 重打包 → rfz_unpack.py 再解包 → 与首次解包逐字节比较。
| 字号 | 解压流 | gate A 重建==模板 | 编码耗时 | 产物 vs 原始 | gate B 往返 | 二次解包逐字节一致 |
|---|---|---|---|---|---|---|
| 240pt | 8,397,312 B | ✔ | 7.9s→<1s¹ | 198.4K vs 179.2K (+10.6%) | ✔ | ✔ (bin/dds/csv/json) |
| 14pt | 13,414,560 B | ✔ | 2.7s | 2.4M vs 2.3M (+1.2%) | ✔ | ✔ (bin/2×dds/csv/json) |
¹ 240pt 在修复 §2.3 的 acc 收窄后同样降至亚秒级。
- gate A (重建流 == 原模板 decompressed.bin): 验证段 B/C/D/E 重建逻辑正确。
- gate B (
my_decoder(encode(stream)) == stream): 验证 LZW 编码器正确。 - 二次解包一致: 端到端证明
unpack(pack(x)) == x(decompressed.bin + 全部 DDS + glyphs.csv + metadata.json 均逐字节相同)。
LZW 重置次数: 14pt 460 次 (原始 455, 差异源于无 KwKwK 导致码字略多), 240pt 37 次。
8. 用法
python rfz_pack.py <unpacked_dir> [output.rfz] [--emit-bin]
unpacked_dir : rfz_unpack.py 的产物目录 (须含 decompressed.bin 作模板)
output.rfz : 输出路径 (默认 <dir>_repacked.rfz)
--emit-bin : 额外输出重建后的 <output>.decompressed.bin
运行时自动执行 gate A / gate B 自检; gate B 不通过会 raise (拒绝产出错误文件)。
9. 结论
RFZ 重打包 = [数据区从 json/csv/dds 重建] + [框架区从模板复制] → 拼流 → LZW 编码 → 加 4B 头。
两个关键正确性要素: (1) LZW 编码器与解码器严格对偶 (加表/早切/4094 重置/位累加器收窄);
(2) 段边界锚点探测而非硬编码。两个字号端到端逐字节验证通过。