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.py、font/rfz_unpack.py(增强)。 所有结构事实经大四应用.exeIDA 复核 + 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 复核逐字节一致 —— namespaceyabukita(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, 其中svo为font_svo_pack.build_font_svo()的输出 (真实 svo, 不含段H 的 19 字节零尾)。
1.2 哈希与 payload 边界 (易错点)
- 外层 YABX hash (偏移 0x0c) = CRC-32/BZIP2(payload) —— 非反射, poly
0x04C11DB7, init0xFFFFFFFF, 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 xml → ElementTree 解析。
不依据文件名判断格式 (用户要求): _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是 "行顶向下到字形顶", SEGAorigin_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 解析与资源基名推断 (.fnt 的 page 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按 DDSdwFlags正确切块 (§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_base 从 page0 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)。