feat(font): decode original Tfnt resources
Docs Deploy / Build and Deploy MkDocs (push) Successful in 44s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 44s
This commit is contained in:
6 files changed
+484
-9
No files matched your search
Generated
+8
@@ -487,6 +487,14 @@ dependencies = [
|
|||||||
name = "fparkan-binary"
|
name = "fparkan-binary"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fparkan-font"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"fparkan-nres",
|
||||||
|
"fparkan-texm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fparkan-fx"
|
name = "fparkan-fx"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ members = [
|
|||||||
"crates/fparkan-animation",
|
"crates/fparkan-animation",
|
||||||
"crates/fparkan-assets",
|
"crates/fparkan-assets",
|
||||||
"crates/fparkan-binary",
|
"crates/fparkan-binary",
|
||||||
|
"crates/fparkan-font",
|
||||||
"crates/fparkan-fx",
|
"crates/fparkan-fx",
|
||||||
"crates/fparkan-material",
|
"crates/fparkan-material",
|
||||||
"crates/fparkan-mission-format",
|
"crates/fparkan-mission-format",
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -648,11 +648,15 @@ Native interoperability подтверждается только успешны
|
|||||||
форматы: `gamefont.rlb` читается общим RsLi reader, но его native consumer этим
|
форматы: `gamefont.rlb` читается общим RsLi reader, но его native consumer этим
|
||||||
не установлен; для меню подтверждена цепочка `menu_resources.cfg` → NRes
|
не установлен; для меню подтверждена цепочка `menu_resources.cfg` → NRes
|
||||||
`font.lib` → native lookup `MENU_FONT` с получением реального font-объекта.
|
`font.lib` → native lookup `MENU_FONT` с получением реального font-объекта.
|
||||||
`menu_resources.cfg` и шесть вариантов `MENU_FONT`/`GAME_FONT` сопоставлены с
|
Reader `fparkan-font` разбирает 20-байтовый Tfnt prefix, 256 записей фиксированного
|
||||||
NRes-записями в [разборе shell и шрифтов](ui-shell.md). Остаются неизвестными
|
шага и передаёт вложенный Texm общему reader-у. Native consumer подтверждает
|
||||||
glyph metrics и drawing, выбор dimension на фактическом дисплее `1280x1024`,
|
два целочисленных параметра, per-byte spacing и signed advance; смысл трёх glyph DWORD
|
||||||
widget tree, layout rules, sprite command semantics, focus/navigation и HUD
|
и поля заголовка по `0x0c` пока не установлен. Это не реализует выбор шрифта, текстовый
|
||||||
state machine.
|
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`,
|
Закрывающий эксперимент: сопоставить оставшиеся детали `shell_ctrls.cfg`,
|
||||||
`cursor.cfg`, `game_resources.cfg` и `hq.cfg` с GUI object factories и снять
|
`cursor.cfg`, `game_resources.cfg` и `hq.cfg` с GUI object factories и снять
|
||||||
|
|||||||
@@ -77,6 +77,53 @@ quirk для `INTERF8.TEX`. В полной GOG-сборке меню отдел
|
|||||||
реального font-объекта, но не конкретной NRes-записи при `1280x1024`, её
|
реального 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 — четыре
|
Ключ ресурса, legacy path, локализованный текст и исходные bytes — четыре
|
||||||
разные идентичности. Archive keys остаются ASCII-casefold; `TextRes.cfg`,
|
разные идентичности. Archive keys остаются ASCII-casefold; `TextRes.cfg`,
|
||||||
briefing/messages и script strings могут требовать ANSI/CP1251 decoding.
|
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 и
|
viewport, scale/letterbox policy, mouse-to-layout transform, clipping и
|
||||||
z-order. Cursor, sprite и hit-test обязаны применять одно преобразование.
|
z-order. Cursor, sprite и hit-test обязаны применять одно преобразование.
|
||||||
|
|
||||||
Font contract включает glyph image, advance, bearing/offset, line height и
|
Font contract включает glyph image, bearing/offset, line height и fallback
|
||||||
fallback glyph. Содержимое `gamefont.rlb` и `Tfnt` payload записей `.tft`
|
glyph. `fparkan-font` читает фиксированную таблицу Tfnt и отдаёт signed advance
|
||||||
сохраняется без потери bytes через общие readers (`RsLi` и `NRes`), но семантика
|
и embedded Texm; он не выполняет text measurement/layout или glyph drawing.
|
||||||
метрик и глифов ещё не восстановлена по consumer trace.
|
`gamefont.rlb` по-прежнему читается общим RsLi reader, но его native consumer не
|
||||||
|
установлен.
|
||||||
|
|
||||||
Нормализованное событие проходит modal widget, затем shell command map; лишь
|
Нормализованное событие проходит modal widget, затем shell command map; лишь
|
||||||
разрешённая gameplay-команда превращается в World3D manual event. Held state и
|
разрешённая gameplay-команда превращается в World3D manual event. Held state и
|
||||||
|
|||||||
Reference in new issue
Block a user