跳转至

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_F270F0sub_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) 段边界锚点探测而非硬编码。两个字号端到端逐字节验证通过。

评论