From 146db05e12a7ef40e1e313307127cb73919772eb Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Fri, 30 Oct 2026 16:56:53 +0400 Subject: [PATCH] feat(font): decode original Tfnt resources --- Cargo.lock | 8 + Cargo.toml | 1 + crates/fparkan-font/Cargo.toml | 12 + crates/fparkan-font/src/lib.rs | 402 ++++++++++++++++++++++++ docs/appendices/knowledge-boundaries.md | 14 +- docs/appendices/ui-shell.md | 56 +++- 6 files changed, 484 insertions(+), 9 deletions(-) create mode 100644 crates/fparkan-font/Cargo.toml create mode 100644 crates/fparkan-font/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index b533543..36487eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -487,6 +487,14 @@ dependencies = [ name = "fparkan-binary" version = "0.1.0" +[[package]] +name = "fparkan-font" +version = "0.1.0" +dependencies = [ + "fparkan-nres", + "fparkan-texm", +] + [[package]] name = "fparkan-fx" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 847b23c..5711d16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/fparkan-animation", "crates/fparkan-assets", "crates/fparkan-binary", + "crates/fparkan-font", "crates/fparkan-fx", "crates/fparkan-material", "crates/fparkan-mission-format", diff --git a/crates/fparkan-font/Cargo.toml b/crates/fparkan-font/Cargo.toml new file mode 100644 index 0000000..3053a3e --- /dev/null +++ b/crates/fparkan-font/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fparkan-font" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-texm = { path = "../fparkan-texm" } + +[dev-dependencies] +fparkan-nres = { path = "../fparkan-nres" } diff --git a/crates/fparkan-font/src/lib.rs b/crates/fparkan-font/src/lib.rs new file mode 100644 index 0000000..770ebb0 --- /dev/null +++ b/crates/fparkan-font/src/lib.rs @@ -0,0 +1,402 @@ +#![forbid(unsafe_code)] +//! Safe reader for the fixed Tfnt metrics and its embedded Texm image. + +use fparkan_texm::{TexmDocument, TexmError}; +use std::sync::Arc; + +const PREFIX_LEN: usize = 0x14; +const GLYPH_TABLE_END: usize = 0x1014; +const GLYPH_COUNT: usize = 256; + +/// Confirmed Tfnt metrics and the preserved opaque word at offset `0x0c`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TfntHeader { + /// Lower bound used by the native fixed-width drawing path. + pub fixed_width_floor: i32, + /// Vertical extent used by native clipping and drawing. + pub vertical_extent: i32, + /// Raw bits copied from offset `0x0c`; its semantic name is unknown. + pub word_0c_bits: u32, + /// Per-input-byte spacing used by native text measurement. + pub character_spacing: i32, +} + +/// One 16-byte glyph record; the first three words remain opaque. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TfntGlyph { + /// First opaque DWORD. + pub word0: u32, + /// Second opaque DWORD. + pub word1: u32, + /// Third opaque DWORD. + pub word2: u32, + /// Signed advance at record offset `0x0c`. + pub advance: i32, +} + +/// Decoded Tfnt prefix, fixed glyph table, and embedded Texm document. +#[derive(Clone, Debug)] +pub struct TfntDocument { + /// Header metrics. + pub header: TfntHeader, + /// Records indexed by the native unsigned input byte, `0..=255`. + pub glyphs: [TfntGlyph; GLYPH_COUNT], + /// Texture document parsed by the shared Texm reader. + pub texm: TexmDocument, +} + +/// Tfnt decode error. +#[derive(Debug)] +pub enum TfntError { + /// The safe file reader requires the corpus signature `Tfnt`. + InvalidMagic { got: [u8; 4] }, + /// The fixed 20-byte prefix is incomplete. + TruncatedHeader { actual: usize, required: usize }, + /// The fixed 256-record table is incomplete. + TruncatedGlyphTable { actual: usize, required: usize }, + /// The embedded texture could not be decoded. + Texm(TexmError), +} + +impl std::fmt::Display for TfntError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidMagic { got } => write!(f, "invalid Tfnt magic: {got:02X?}"), + Self::TruncatedHeader { actual, required } => { + write!(f, "Tfnt header truncated: {actual} bytes, need {required}") + } + Self::TruncatedGlyphTable { actual, required } => { + write!( + f, + "Tfnt glyph table truncated: {actual} bytes, need {required}" + ) + } + Self::Texm(source) => write!(f, "embedded Texm: {source}"), + } + } +} + +impl std::error::Error for TfntError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Texm(source) => Some(source), + Self::InvalidMagic { .. } + | Self::TruncatedHeader { .. } + | Self::TruncatedGlyphTable { .. } => None, + } + } +} + +/// Decodes a Tfnt file and its embedded Texm texture. +/// +/// # Errors +/// +/// Returns [`TfntError`] for a bad signature, an incomplete fixed header or +/// glyph table, or an invalid embedded Texm document. +pub fn decode_tfnt(bytes: Arc<[u8]>) -> Result { + if bytes.len() < PREFIX_LEN { + return Err(TfntError::TruncatedHeader { + actual: bytes.len(), + required: PREFIX_LEN, + }); + } + + let mut magic = [0; 4]; + magic.copy_from_slice(&bytes[..4]); + if magic != *b"Tfnt" { + return Err(TfntError::InvalidMagic { got: magic }); + } + + if bytes.len() < GLYPH_TABLE_END { + return Err(TfntError::TruncatedGlyphTable { + actual: bytes.len(), + required: GLYPH_TABLE_END, + }); + } + + let header = TfntHeader { + fixed_width_floor: read_i32_le(&bytes, 0x04), + vertical_extent: read_i32_le(&bytes, 0x08), + word_0c_bits: read_u32_le(&bytes, 0x0c), + character_spacing: read_i32_le(&bytes, 0x10), + }; + let mut glyphs = [TfntGlyph::default(); GLYPH_COUNT]; + for (glyph, record) in glyphs + .iter_mut() + .zip(bytes[PREFIX_LEN..GLYPH_TABLE_END].chunks_exact(16)) + { + *glyph = TfntGlyph { + word0: read_u32_le(record, 0), + word1: read_u32_le(record, 4), + word2: read_u32_le(record, 8), + advance: read_i32_le(record, 12), + }; + } + + let texm = + fparkan_texm::decode_texm(Arc::from(&bytes[GLYPH_TABLE_END..])).map_err(TfntError::Texm)?; + + Ok(TfntDocument { + header, + glyphs, + texm, + }) +} + +fn read_u32_le(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes( + bytes[offset..offset + 4] + .try_into() + .expect("fixed range was bounds-checked"), + ) +} + +fn read_i32_le(bytes: &[u8], offset: usize) -> i32 { + i32::from_le_bytes( + bytes[offset..offset + 4] + .try_into() + .expect("fixed range was bounds-checked"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_nres::{decode as decode_nres, ReadProfile}; + use fparkan_texm::PixelFormat; + use std::path::PathBuf; + + #[test] + fn reads_distinct_header_values_and_preserves_nan_bits() { + let mut bytes = tfnt_prefix(); + put_u32(&mut bytes, 0x04, (-17_i32) as u32); + put_u32(&mut bytes, 0x08, 513); + put_u32(&mut bytes, 0x0c, 0x7fc1_2345); + put_u32(&mut bytes, 0x10, (-3_i32) as u32); + bytes.extend_from_slice(&indexed_texm(2, 1, &[0xA5, 0x5A])); + + let document = decode_tfnt(Arc::from(bytes.into_boxed_slice())).expect("Tfnt"); + + assert_eq!(document.header.fixed_width_floor, -17); + assert_eq!(document.header.vertical_extent, 513); + assert_eq!(document.header.word_0c_bits, 0x7fc1_2345); + assert_eq!(document.header.character_spacing, -3); + } + + #[test] + fn preserves_distinct_glyph_records_including_byte_255_and_signed_advances() { + let mut bytes = tfnt_prefix(); + write_glyph( + &mut bytes, + 7, + [0x7fc1_2345, 0x89ab_cdef, 0x0102_0304, (-23_i32) as u32], + ); + write_glyph( + &mut bytes, + 255, + [0x8000_0000, 0x7654_3210, 0xffff_ffff, i32::MIN as u32], + ); + bytes.extend_from_slice(&indexed_texm(2, 1, &[1, 2])); + + let document = decode_tfnt(Arc::from(bytes.into_boxed_slice())).expect("Tfnt"); + + assert_eq!( + document.glyphs[7], + TfntGlyph { + word0: 0x7fc1_2345, + word1: 0x89ab_cdef, + word2: 0x0102_0304, + advance: -23, + } + ); + assert_eq!(document.glyphs[255].word0, 0x8000_0000); + assert_eq!(document.glyphs[255].word1, 0x7654_3210); + assert_eq!(document.glyphs[255].word2, u32::MAX); + assert_eq!(document.glyphs[255].advance, i32::MIN); + } + + #[test] + fn delegates_embedded_texture_to_texm_reader() { + let mut bytes = tfnt_prefix(); + bytes.extend_from_slice(&indexed_texm(2, 1, &[0xA5, 0x5A])); + + let document = decode_tfnt(Arc::from(bytes.into_boxed_slice())).expect("Tfnt"); + + assert_eq!(document.texm.width(), 2); + assert_eq!(document.texm.height(), 1); + assert_eq!(document.texm.mip_count(), 1); + assert_eq!(document.texm.format(), PixelFormat::Indexed8); + assert_eq!(document.texm.mip_level(0).expect("mip").bytes, [0xA5, 0x5A]); + } + + #[test] + fn rejects_wrong_magic_and_short_prefix() { + let mut wrong_magic = tfnt_prefix(); + wrong_magic[..4].copy_from_slice(b"BAD!"); + assert!(matches!( + decode_tfnt(Arc::from(wrong_magic.into_boxed_slice())), + Err(TfntError::InvalidMagic { .. }) + )); + + let mut short_prefix = tfnt_prefix(); + short_prefix.truncate(PREFIX_LEN - 1); + assert!(matches!( + decode_tfnt(Arc::from(short_prefix.into_boxed_slice())), + Err(TfntError::TruncatedHeader { .. }) + )); + } + + #[test] + fn rejects_short_fixed_glyph_table() { + let mut bytes = tfnt_prefix(); + bytes.truncate(GLYPH_TABLE_END - 1); + + assert!(matches!( + decode_tfnt(Arc::from(bytes.into_boxed_slice())), + Err(TfntError::TruncatedGlyphTable { .. }) + )); + } + + #[test] + fn reports_invalid_and_truncated_embedded_texm_without_panicking() { + let mut invalid = tfnt_prefix(); + invalid.extend_from_slice(b"Not a Texm payload, but long enough to reach its reader."); + assert!(matches!( + decode_tfnt(Arc::from(invalid.into_boxed_slice())), + Err(TfntError::Texm(_)) + )); + + let mut truncated = tfnt_prefix(); + truncated.extend_from_slice(b"Texm"); + assert!(matches!( + decode_tfnt(Arc::from(truncated.into_boxed_slice())), + Err(TfntError::Texm(_)) + )); + } + + #[test] + #[ignore = "requires licensed GOG corpus"] + fn decodes_original_gog_tfnt_resources() { + let root = std::env::var_os("FPARKAN_CORPUS_PART1_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| panic!("FPARKAN_CORPUS_PART1_ROOT is required for this test")); + assert!( + root.is_dir(), + "licensed corpus root is missing: {}", + root.display() + ); + + let font_bytes = std::fs::read(root.join("ui/font.lib")).expect("read ui/font.lib"); + let fonts = decode_nres( + Arc::from(font_bytes.into_boxed_slice()), + ReadProfile::Strict, + ) + .expect("decode ui/font.lib"); + let expected = [ + ("gf_1024.tft", 256, 256), + ("tf_1024.tft", 256, 256), + ("mf_1024.tft", 256, 256), + ("gf_800.tft", 128, 128), + ("tf_800.tft", 128, 128), + ("mf_800.tft", 256, 256), + ("gf_640.tft", 128, 128), + ("tf_640.tft", 128, 128), + ("mf_640.tft", 256, 256), + ]; + assert_eq!(fonts.entry_count(), expected.len()); + for (name, width, height) in expected { + let id = fonts + .find(name) + .unwrap_or_else(|| panic!("missing NRes font {name}")); + let entry = fonts.entry(id).expect("font entry"); + assert_eq!(entry.meta().type_id, u32::from_le_bytes(*b"FONT"), "{name}"); + let document = decode_tfnt(Arc::from(fonts.payload(id).expect("font payload"))) + .unwrap_or_else(|err| panic!("{name}: {err}")); + assert_eq!(document.texm.width(), width, "{name}"); + assert_eq!(document.texm.height(), height, "{name}"); + assert_eq!(document.texm.format(), PixelFormat::Indexed8, "{name}"); + assert_eq!(document.texm.mip_count(), 1, "{name}"); + assert_eq!( + document.texm.mip_level(0).expect("mip").bytes.len(), + width as usize * height as usize, + "{name}" + ); + if name == "gf_640.tft" { + assert_eq!( + document.header, + TfntHeader { + fixed_width_floor: 8, + vertical_extent: 7, + word_0c_bits: 0x3d80_0000, + character_spacing: 1, + } + ); + assert_eq!( + document.glyphs[65], + TfntGlyph { + word0: 0x3c00_0000, + word1: 0x3d80_0000, + word2: 0x3e80_0000, + advance: 6, + } + ); + assert_eq!(document.glyphs[255].advance, 5); + } + } + + let sys_bytes = std::fs::read(root.join("sys.lib")).expect("read sys.lib"); + let sys = decode_nres(Arc::from(sys_bytes.into_boxed_slice()), ReadProfile::Strict) + .expect("decode sys.lib"); + let id = sys.find("ARIALTEX.TFT").expect("ARIALTEX.TFT entry"); + let entry = sys.entry(id).expect("ARIALTEX.TFT entry"); + assert_eq!(entry.meta().type_id, u32::from_le_bytes(*b"FONT")); + let document = decode_tfnt(Arc::from(sys.payload(id).expect("ARIALTEX.TFT payload"))) + .expect("decode ARIALTEX.TFT"); + assert_eq!(document.texm.width(), 128); + assert_eq!(document.texm.height(), 128); + assert_eq!(document.texm.format(), PixelFormat::Indexed8); + assert_eq!(document.texm.mip_count(), 1); + assert_eq!( + document.texm.mip_level(0).expect("mip").bytes.len(), + 128 * 128 + ); + assert_eq!( + document.header, + TfntHeader { + fixed_width_floor: 8, + vertical_extent: 7, + word_0c_bits: 0x3d80_0000, + character_spacing: 1, + } + ); + assert_eq!(document.glyphs[65].advance, 6); + assert_eq!(document.glyphs[255].advance, 4); + } + + fn tfnt_prefix() -> Vec { + let mut bytes = vec![0; GLYPH_TABLE_END]; + bytes[..4].copy_from_slice(b"Tfnt"); + bytes + } + + fn indexed_texm(width: u32, height: u32, pixels: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(32 + 1024 + pixels.len()); + for word in [0x6d78_6554, width, height, 1, 0x20, 0x0100_0000, 0, 0] { + bytes.extend_from_slice(&word.to_le_bytes()); + } + bytes.extend_from_slice(&[0; 1024]); + bytes.extend_from_slice(pixels); + bytes + } + + fn put_u32(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn write_glyph(bytes: &mut [u8], index: usize, words: [u32; 4]) { + let offset = PREFIX_LEN + index * 16; + for (word_index, value) in words.into_iter().enumerate() { + put_u32(bytes, offset + word_index * 4, value); + } + } +} diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index 53244c7..c58462b 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -648,11 +648,15 @@ Native interoperability подтверждается только успешны форматы: `gamefont.rlb` читается общим RsLi reader, но его native consumer этим не установлен; для меню подтверждена цепочка `menu_resources.cfg` → NRes `font.lib` → native lookup `MENU_FONT` с получением реального font-объекта. -`menu_resources.cfg` и шесть вариантов `MENU_FONT`/`GAME_FONT` сопоставлены с -NRes-записями в [разборе shell и шрифтов](ui-shell.md). Остаются неизвестными -glyph metrics и drawing, выбор dimension на фактическом дисплее `1280x1024`, -widget tree, layout rules, sprite command semantics, focus/navigation и HUD -state machine. +Reader `fparkan-font` разбирает 20-байтовый Tfnt prefix, 256 записей фиксированного +шага и передаёт вложенный Texm общему reader-у. Native consumer подтверждает +два целочисленных параметра, per-byte spacing и signed advance; смысл трёх glyph DWORD +и поля заголовка по `0x0c` пока не установлен. Это не реализует выбор шрифта, текстовый +layout или glyph drawing. `menu_resources.cfg` и шесть вариантов +`MENU_FONT`/`GAME_FONT` сопоставлены с NRes-записями в +[разборе shell и шрифтов](ui-shell.md). Также остаются открытыми выбор dimension +на фактическом дисплее `1280x1024`, widget tree, layout rules, sprite command +semantics, focus/navigation и HUD state machine. Закрывающий эксперимент: сопоставить оставшиеся детали `shell_ctrls.cfg`, `cursor.cfg`, `game_resources.cfg` и `hq.cfg` с GUI object factories и снять diff --git a/docs/appendices/ui-shell.md b/docs/appendices/ui-shell.md index 7a5b7d1..58d4083 100644 --- a/docs/appendices/ui-shell.md +++ b/docs/appendices/ui-shell.md @@ -77,6 +77,53 @@ quirk для `INTERF8.TEX`. В полной GOG-сборке меню отдел реального font-объекта, но не конкретной NRes-записи при `1280x1024`, её отрисовку или паритет шрифтов. +## Фиксированный reader Tfnt + +В подтверждённом пути `World3D.dll` (SHA-256 +`17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e`) +передаёт ordinal NRes-reader-у в RVA `0x1361f`, а затем передаёт полученный +указатель renderer-у в RVA `0x13624`; бит `0x400000` установлен у вызова в RVA +`0x1360d`. В `Ngi32.dll` (SHA-256 +`bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5`) vtable +NRes у RVA `0x318bc` ведёт к `FUN_10011f80` (RVA `0x11f80`), которая возвращает +`mapped_base + data_offset`. Renderer vtable у RVA `0x315e0` направляет slot +`+0x58` в `FUN_10007dd0` (RVA `0x7dd0`), затем ресурс проходит через +`FUN_10010c10` (RVA `0x10c10`) и хранит pointer в `FUN_1000f3d0` (RVA `0x0f3d0`). +Loader font-объекта `FUN_1000fb30` (RVA `0x0fb30`) при флаге `0x400000` +использует этот адрес как начало Tfnt payload; отдельная RsLi-обёртка в этом +пути не читается. + +| Смещение | Что читает `fparkan-font` | +| --- | --- | +| `0x00..0x03` | Сигнатура `Tfnt`; reader проверяет её на границе файлового API. В наблюдавшемся native loader проверки сигнатуры не было. | +| `0x04..0x07` | `i32`, нижняя граница ширины в native fixed-width path. | +| `0x08..0x0b` | `i32`, вертикальный размер для native clipping/drawing. | +| `0x0c..0x0f` | Сырые биты `u32`, скопированные в font object и участвующие в float-сложении при drawing; имя и смысл поля неизвестны. | +| `0x10..0x13` | `i32`, прибавляемый один раз на входной байт при native text measurement. | +| `0x14..0x1013` | 256 записей по 16 байт: три сырых DWORD и signed `i32` advance по смещению `+0x0c`. | +| `0x1014..конец` | Обычный Texm-документ, передаваемый общему `fparkan-texm::decode_texm`. | + +Первые три слова glyph record имеют разные native consumers, но их общая +семантика не восстановлена: одно слово загружается как float, другое попадает +в сформированные vertex data, а третье не прояснилось в исследованных ветвях. +Поэтому reader сохраняет их как `word0..word2`, а поле заголовка по смещению +`0x0c` — как неинтерпретированные float-биты. Он не выбирает размер текстуры по разрешению +дисплея и не задаёт диапазон размеров: в проверенных ресурсах встречаются +`128×128` и `256×256`, а форматы, mip-цепочку и палитру проверяет общий Texm +reader. + +```rust +let font = decode_tfnt(Arc::<[u8]>::from(raw_font_payload))?; +let glyph = font.glyphs[usize::from(input_byte)]; +let texture = font.texm; +``` + +Игнорируемый corpus-тест разбирает через общий NRes reader девять записей +`ui/font.lib` и `ARIALTEX.TFT` из `sys.lib`; он сверяет известные размеры, +Indexed8, одну mip-ступень и выборочные поля `gf_640.tft`. Это проверка байтов +лицензированной GOG-сборки и parser contract, а не запуск shell или проверка +отрисовки. + Ключ ресурса, legacy path, локализованный текст и исходные bytes — четыре разные идентичности. Archive keys остаются ASCII-casefold; `TextRes.cfg`, briefing/messages и script strings могут требовать ANSI/CP1251 decoding. @@ -95,10 +142,11 @@ design resolution. Отдельно хранятся legacy-layout coordinates, viewport, scale/letterbox policy, mouse-to-layout transform, clipping и z-order. Cursor, sprite и hit-test обязаны применять одно преобразование. -Font contract включает glyph image, advance, bearing/offset, line height и -fallback glyph. Содержимое `gamefont.rlb` и `Tfnt` payload записей `.tft` -сохраняется без потери bytes через общие readers (`RsLi` и `NRes`), но семантика -метрик и глифов ещё не восстановлена по consumer trace. +Font contract включает glyph image, bearing/offset, line height и fallback +glyph. `fparkan-font` читает фиксированную таблицу Tfnt и отдаёт signed advance +и embedded Texm; он не выполняет text measurement/layout или glyph drawing. +`gamefont.rlb` по-прежнему читается общим RsLi reader, но его native consumer не +установлен. Нормализованное событие проходит modal widget, затем shell command map; лишь разрешённая gameplay-команда превращается в World3D manual event. Held state и