跳转至

bmfont → RFZ 全新字体打包 (模板无关) 规格

状态: 已打通并逐字节验证 (2026-06-14)。 关联: rfz_unpack_spec.md (解压/容器)、rfz_pack_spec.md (LZW 编码器 + 模板法打包)、 svo_format.md §15 (内嵌纹理 svo 生成)、rfz_glyph_coords_analysis.md (字形坐标语义)。 工具: font/bmfont_to_rfz.py (新)、font/font_svo_pack.pyfont/rfz_unpack.py (增强)。 所有结构事实经 大四应用.exe IDA 复核 + 14pt/240pt 模板逐字节往返验证。

rfz_pack_spec.md §6.1 曾指出: 模板法打包器整段复制纹理段, 无法支持任意页数/字体名的 全新字体。本规格记录如何完全脱离 decompressed.bin 模板, 仅凭 bmfont 产物 (.fnt + 多页 DDS) 程序化重建整条 RFZ。该缺口现已关闭。


1. decompressed.bin 段结构 (模板无关重建)

解压后的 yabukita 序列化流由以下段顺序拼接, 全部可程序化生成 (无需模板):

内容 来源
外层 YABX 头 "YABX" + u32(version=1) + u32(payload_size) + u32(crc) (16B) 计算
top header u8(7) + long_str("ruhuna::Database") + u8(0) + short_str(font_base) font_base
schema namespaces + 4 类定义 (522B, 字体无关常量) SCHEMA_CONST
实例头 u16(lead_a=0) + u16(obj_count) + u16(tag=2) + u32(body_size) 计算
段B Database 标量/字符串前缀 (id..glyph_cnt) metadata
段C Database._glyph reflist (localID 数组) glyph_cnt
段D Database._texture reflist (1 个 TextureResource) glyph_cnt
段E 逐字形对象帧 (tag=3, body=30) × glyph_cnt glyphs
段F/G TextureResource 帧 + 内嵌纹理 svo (AVTS) font_svo_pack
段H 19 字节全零尾 常量

1.1 关键量与编码

  • SCHEMA_CONST (522B): 经 14pt/240pt 复核逐字节一致 —— namespace yabukita(id=1005)/ ruhuna(id=537202737) + 类 ruhuna::Object / Database(19 字段) / Glyph(12 字段) / TextureResource(1 字段 file, type 0x18 blob)。与字号/字体/页数无关, 直接内嵌为 base64。
  • obj_count = glyph_cnt + 2 (Database + 各字形 + 1 个 TextureResource)。
  • Database 帧 body_size = len(段B) + len(段C) + len(段D) (字形帧/TextureResource 帧不计入)。
  • localID: Database=10001 (0x2711); 字形从 0x2712 起递增; TextureResource = 0x2712 + glyph_cnt。
  • 段C = u32(size) + u32(count) + count×u16(localID); 段D = u32(6) + u32(1) + u16(tex_localID)
  • 段E 每帧 = u16(3) + u32(30) + body, body = code(u16) + 10 字段 + kerning, kerning = u32(4) + u32(0) (cnt=0)。
  • 段F/G (TextureResource 帧) = u16(tag=4) + u32(body=4+svo_len) + u32(svo_len) + svo_bytes, 其中 svofont_svo_pack.build_font_svo() 的输出 (真实 svo, 不含段H 的 19 字节零尾)。

1.2 哈希与 payload 边界 (易错点)

  • 外层 YABX hash (偏移 0x0c) = CRC-32/BZIP2(payload) —— 非反射, poly 0x04C11DB7, init 0xFFFFFFFF, MSB-first, 末取反。实测匹配: 14pt=0xb61d7a27, 240pt=0xfca9c3f1。 加载默认不校验 (IDA: sub_E91400 仅校验 magic), 但本器恒填正确值
  • 段H (19 字节全零尾) 属于 payload —— 计入 payload_size 与 CRC。即 payload = top + schema + inst + 段B..E + TexResFrame + 19×\0, payload_size = len(payload)。早期遗漏此 19 字节导致 size/CRC 差 19 (已修正)。

验证: build_decompressed() 复现 14pt(13,414,560B)/240pt(8,397,312B) 的 decompressed.bin 逐字节一致


2. 字形字段类型 (IDA 复核, 决定有符号性)

字形 12 个字段在游戏 schema 中各自注册了类型字符串 (反射注册函数 sub_F26xxx/sub_F270xx, 调用 sub_43969E("<type>")):

字段 注册类型 注册函数 编码
cell_inc_x u16 sub_F26FB0 <H
cell_inc_y u16 sub_F26FE0 <H
box_x1 u16 sub_F26EF0 <H
box_y1/x2/y2 u16 <H
origin_x s16 sub_F270C0 <h (有符号!)
origin_y s16 sub_F270F0 <h (有符号!)

结论: origin_x/origin_y有符号 16 位, 其余字段为无符号 16 位。

意义: - bmfont 因 padding 常给出 xoffset/yoffset (如 SEGAHUMMING 32pt 有 11,778 个负偏移), 必须按 s16 原样保留 (v & 0xFFFF 两补码), 不可钳到 0 (否则字形整体偏移、抵消不掉 padding)。 - SEGA 原始字体的空格字形 (如 U+3000) 用 origin_x=origin_y=0xFFFF 作哨兵, 按 s16 即 -1; box 字段同为 0xFFFF 但按 u16 解读为 65535。

rfz_unpack.py / rfz_pack.py / bmfont_to_rfz.py 三者均已统一: GLYPH_SIGNED = (origin_x, origin_y), 读写时对该两字段用有符号编码, 保证往返一致。


3. bmfont 字段映射

3.1 .fnt 解析 (两种格式, 由命令行参数决定)

--format text → 解析纯文本 key=value 行; --format xmlElementTree 解析。 不依据文件名判断格式 (用户要求): _text.fnt/_xml.fnt 只是命名约定。两格式字段完全一致, 实测重建出的 decompressed.bin 逐字节相同

3.2 common/info → metadata

metadata 字段 来源 备注
id / platform / library "RHFONTDB" / "DXPC" / "ceylon" 常量
name info.face --name 覆盖
comment 同 name --comment 覆盖
flags 0
point abs(info.size) --point 覆盖
max_ascent common.base
max_descent common.lineHeight - base (≥0)
max_glyph_w/h 全字形 width/height 最大值
tex_page common.pages (= DDS 页数)
tex_w / tex_h common.scaleW / scaleH
tex_last_h = tex_h bmfont 不裁剪末页
glyph_margin info.padding 上值 --glyph-margin 覆盖
glyph_cnt 有效字形数

3.3 char → glyph

glyph 字段 bmfont char 备注
code id >0xFFFF 跳过 (RFO code 为 u16)
cell_inc_x xadvance
cell_inc_y 恒 0
page page
origin_x / origin_y xoffset / base − yoffset origin_y 换算到"基线向上"参考系 (见下注)
box_x1 / box_y1 x / y DDS 图集像素左上
box_x2 / box_y2 x+width / y+height 图集像素右下
kerning_info_cnt 恒 0 (不导出 kerning 对)

字形按 code 升序排列 (与 SEGA 原始一致)。

origin_y 参考系换算 (修正"整行下沉"): bmfont yoffset 是 "行顶向下到字形顶", SEGA origin_y 是 "基线向上到字形顶" (上正下负, s16; 逗号/句点为负坐实, 见 rfz_glyph_coords_analysis.md §3.4)。二者相反, 必须 origin_y = base − yoffset, 否则游戏内整行字下沉约 base 像素。bmfont_to_rfz.fnt_to_meta_glyphs 已按此换算。


3.5 内嵌 SVO 的 AVTS 版本 (避免 c0000005 崩溃)

内嵌纹理 SVO 的 AVTS 头 (偏移 0x4, u32) 是版本/容量字段。各 SEGA 原始字体实测:

字体 DDS 页数 AVTS version point max_ascent
RFO_SEGAKAKUGOTHIC_DB_14pt 2 3 10 13
RFO_SEGAKAKUGOTHIC_DB_18pt 3 4 14 19
RFO_SEGAKAKUGOTHIC_DB_24pt 4 5 18 24
RFO_SEGAKAKUGOTHIC_DB_32pt 6 7 24 32
RFO_SEGAKAKUGOTHIC_DB_60pt 20 21 45 60
RFO_SEGAKAKUGOTHIC_DB_240pt 1 2 180 240

规律: AVTS version = DDS 页数 + 1 (SEGA 恒取该最小值)。

判定为下界而非定值 (经崩溃实验): 4 页 bmfont 用 version 3 → 游戏 c0000005 @0xEBBEA0 (页索引越界, version 被当作 chunk 数组容量, 容量 3 < chunk 数 5); 同一 4 页文件用 version 7 (≥ 5) 可正常加载; 14pt(2 页) 用 version 3 (= 2+1) 也正常。故约束为 version ≥ 页数+1, SEGA 取等号。font_svo_pack.build_font_svo 仅把该字节写入 AVTS+0x4, 不改变目录/帧结构, chunk 数恒按真实页数生成。

工具默认: bmfont_to_rfz.py / font_stage1_assets.py 现默认 avts_version = len(pages)+1 (自动推导), --avts-version 可显式覆盖。早期硬编码默认 3 是 4 页字体崩溃的根因。


4. 纹理 (DDS) 处理

  • 像素格式无需转换: bmfont 与 RFZ 内嵌均为 2048² ARGB4444 16bpp (masks f00/f0/f/f000), 原始字节直接嵌入 svo。
  • DDS 头 dwPitchOrLinearSize 含义有别 (解包切块时关键):
  • SEGA DDS: dwFlags & DDSD_LINEARSIZE(0x80000), 该值 = 整块字节数 (如 8,388,608)。
  • bmfont DDS: dwFlags & DDSD_PITCH(0x8), 该值 = 每行字节数 (如 4096), 整块 = pitch×height。 rfz_unpack.extract_textures 现按 dwFlags 区分; 二者皆缺时按 w×h×2 估算。

5. 完整管线 (font/bmfont_to_rfz.py)

.fnt (--format text|xml) + 多页 DDS
  → parse_fnt → fnt_to_meta_glyphs        (metadata + glyphs, 字段映射 §3)
  → font_svo_pack.build_font_svo          (段F/G: AVTS 目录 + 内层 YABX + DDS)
  → build_decompressed                    (段A..H 程序化, CRC-32/BZIP2, §1)
  → rfz_pack.LzwEncoder.encode + 往返自检  (decode(encode)==stream)
  → "YS"+02 00 + comp                      (4B RFZ 头)

用法:

python bmfont_to_rfz.py <fnt> --format {text|xml} [--dds-dir DIR]
    [--name S] [--comment S] [--point N] [--glyph-margin N]
    [--font-base S] [--avts-version N] [--dump-dir DIR] [-o out.rfz]

--dump-dir 额外导出 metadata.json / glyphs.csv / decompressed.bin / texture.svo 便于核对。

5.1 DDS 解析与资源基名推断 (.fntpage file= 驱动)

.fnt 内每行 page id=N file="..." 给出该页 DDS 的文件名 (text/xml 两格式均解析)。据此:

  • --dds-dir 默认 = .fnt 所在目录 —— fnt/dds/脚本同目录时直接省略该参数即可。
  • DDS 按 file= 名解析: 第 N 页优先用 fnt_pages[N] 指定的文件名, 缺失时回退 <fnt 名去_text/_xml>_N.dds 再回退 <info.face>_N.dds, 全部落空才报错。
  • 资源基名 font_base: 默认从 page0 的 file= 名剥去 _NNNN.dds 后缀推断 (正则 (.+?)_\d{4}\.dds$), 取不到再回退 .fnt 文件名 (去 _text/_xml)。--font-base 可覆盖。 font_base 决定外层 top header 与内嵌 svo 的 __HmfToSvo__<font_base>.svo 自命名, 与解包时 svo_self_name 读取的内嵌名互为逆操作。

例: page file="RFO_SEGAKAKUGOTHIC_DB_32pt_0000.dds"font_base=RFO_SEGAKAKUGOTHIC_DB_32pt, 内嵌 svo 自命名 RFO_SEGAKAKUGOTHIC_DB_32pt.svo, 解包后 svo/DDS 文件名与之逐字节回环一致。

rfz_unpack.py 增强 (本次)

  • 新增内嵌 svo 输出, 按内嵌名命名: svo_self_name 读取 chunk 目录条目 0 的 __HmfToSvo__<font_base>.svo 名 (剥前缀), 无名时回退 texture.svo
  • DDS 按 svo chunk 目录的内嵌名命名 (剥 __HmfToSvo__ 前缀), 无名时回退 page%d.dds
  • extract_textures 按 DDS dwFlags 正确切块 (§4)。
  • parse_glyphs 按 s16 读取 origin_x/origin_y。

6. 验证结果 (2026-06-14)

验证项 结果
build_decompressed 复现 14pt/240pt decompressed.bin 逐字节一致 (13,414,560 / 8,397,312 B)
SCHEMA_CONST 在 14pt/240pt 间 逐字节一致 (522B, 字体无关)
外层 CRC-32/BZIP2 匹配 14pt=0xb61d7a27, 240pt=0xfca9c3f1 ✔
bmfont (SEGAHUMMING 32pt, 4 页) → RFZ → 解包 decompressed.bin / glyphs.csv / metadata.json / svo / 4×DDS 全部逐字节一致
--format text vs --format xml 产物 decompressed.bin 逐字节相同
LZW 往返自检 decode(encode(stream))==stream ✔ (重置 820 次)
font_svo_pack 14pt selftest BYTE-IDENTICAL ✔
rfz_pack (模板法) 240pt gate A/B + 二次解包 ✔ (s16 哨兵正确往返)
省略 --dds-dir (fnt/dds 同目录), DDS 按 page file= 解析 ✔ (4 页全部命中)
font_basepage0 file= 推断 + svo 自命名回环 ✔ (RFO_SEGAKAKUGOTHIC_DB_32pt.svo, 解包 svo/DDS 名一致)
解包 SEGA 14pt rfz → svo 按内嵌名命名 ✔ (RFO_SEGAKAKUGOTHIC_DB_14pt.svo)

SEGAHUMMING 32pt 实测: 12,400 字符中 32 个 code>0xFFFF 被跳过 → 12,368 字形; 解压流 34,033,485B → LZW 4.43MB → RFZ 4.43MB。


7. 已知约束

  • code > 0xFFFF 无法表示 (RFO code 为 u16) —— 超 BMP 字符被跳过并告警。
  • 不导出 kerning 对 (kerning_info_cnt 恒 0); bmfont .fnt 的 kerning 段被忽略。
  • point 默认 = abs(bmfont size) —— 与 SEGA 资产命名 (如 "14pt"→point=10) 的换算无关, 按需 --point 覆盖。
  • LZW 编码器不实现 KwKwK 微优化, 产物与 SEGA 原始 RFZ 非字节一致, 但经同一解码器解出后 与重建流逐字节一致 → 游戏可正确加载 (见 rfz_pack_spec.md §2)。

评论