aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-10-30 15:56:53 +0300
committerValentin Popov <valentin@popov.link>2026-10-30 15:56:53 +0300
commit146db05e12a7ef40e1e313307127cb73919772eb (patch)
treed98846732600114cc16d4f988555cb6f831f8503 /crates
parent637503f32dde1e7a82d9862e476e7f2bc7a18819 (diff)
downloadfparkan-146db05e12a7ef40e1e313307127cb73919772eb.tar.xz
fparkan-146db05e12a7ef40e1e313307127cb73919772eb.zip
feat(font): decode original Tfnt resources
Diffstat (limited to 'crates')
-rw-r--r--crates/fparkan-font/Cargo.toml12
-rw-r--r--crates/fparkan-font/src/lib.rs402
2 files changed, 414 insertions, 0 deletions
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<TfntDocument, TfntError> {
+ 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<u8> {
+ 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<u8> {
+ 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);
+ }
+ }
+}