diff options
Diffstat (limited to 'crates/texm/src')
| -rw-r--r-- | crates/texm/src/error.rs | 61 | ||||
| -rw-r--r-- | crates/texm/src/lib.rs | 258 | ||||
| -rw-r--r-- | crates/texm/src/tests.rs | 150 |
3 files changed, 469 insertions, 0 deletions
diff --git a/crates/texm/src/error.rs b/crates/texm/src/error.rs new file mode 100644 index 0000000..a5dda77 --- /dev/null +++ b/crates/texm/src/error.rs @@ -0,0 +1,61 @@ +use core::fmt; + +#[derive(Debug)] +pub enum Error { + HeaderTooSmall { + size: usize, + }, + InvalidMagic { + got: u32, + }, + InvalidDimensions { + width: u32, + height: u32, + }, + InvalidMipCount { + mip_count: u32, + }, + UnknownFormat { + format: u32, + }, + IntegerOverflow, + CoreDataOutOfBounds { + expected_end: usize, + actual_size: usize, + }, + InvalidPageMagic, + InvalidPageSize { + expected: usize, + actual: usize, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::HeaderTooSmall { size } => { + write!(f, "Texm payload too small for header: {size}") + } + Self::InvalidMagic { got } => write!(f, "invalid Texm magic: 0x{got:08X}"), + Self::InvalidDimensions { width, height } => { + write!(f, "invalid Texm dimensions: {width}x{height}") + } + Self::InvalidMipCount { mip_count } => write!(f, "invalid Texm mip_count={mip_count}"), + Self::UnknownFormat { format } => write!(f, "unknown Texm format={format}"), + Self::IntegerOverflow => write!(f, "integer overflow"), + Self::CoreDataOutOfBounds { + expected_end, + actual_size, + } => write!( + f, + "Texm core data out of bounds: expected_end={expected_end}, actual_size={actual_size}" + ), + Self::InvalidPageMagic => write!(f, "Texm tail exists but Page magic is missing"), + Self::InvalidPageSize { expected, actual } => { + write!(f, "invalid Page chunk size: expected={expected}, actual={actual}") + } + } + } +} + +impl std::error::Error for Error {} diff --git a/crates/texm/src/lib.rs b/crates/texm/src/lib.rs new file mode 100644 index 0000000..c3616d5 --- /dev/null +++ b/crates/texm/src/lib.rs @@ -0,0 +1,258 @@ +pub mod error; + +use crate::error::Error; + +pub type Result<T> = core::result::Result<T, Error>; + +pub const TEXM_MAGIC: u32 = 0x6D78_6554; +pub const PAGE_MAGIC: u32 = 0x6567_6150; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum PixelFormat { + Indexed8, + Rgb565, + Rgb556, + Argb4444, + LuminanceAlpha88, + Rgb888, + Argb8888, +} + +impl PixelFormat { + pub fn from_raw(raw: u32) -> Option<Self> { + match raw { + 0 => Some(Self::Indexed8), + 565 => Some(Self::Rgb565), + 556 => Some(Self::Rgb556), + 4444 => Some(Self::Argb4444), + 88 => Some(Self::LuminanceAlpha88), + 888 => Some(Self::Rgb888), + 8888 => Some(Self::Argb8888), + _ => None, + } + } + + pub fn bytes_per_pixel(self) -> usize { + match self { + Self::Indexed8 => 1, + Self::Rgb565 | Self::Rgb556 | Self::Argb4444 | Self::LuminanceAlpha88 => 2, + Self::Rgb888 | Self::Argb8888 => 4, + } + } +} + +#[derive(Clone, Debug)] +pub struct Header { + pub width: u32, + pub height: u32, + pub mip_count: u32, + pub flags4: u32, + pub flags5: u32, + pub unk6: u32, + pub format_raw: u32, + pub format: PixelFormat, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct MipLevel { + pub width: u32, + pub height: u32, + pub offset: usize, + pub size: usize, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct PageRect { + pub x: i16, + pub w: i16, + pub y: i16, + pub h: i16, +} + +#[derive(Clone, Debug)] +pub struct Texture { + pub header: Header, + pub palette: Option<[u8; 1024]>, + pub mip_levels: Vec<MipLevel>, + pub page_rects: Vec<PageRect>, +} + +impl Texture { + pub fn core_size(&self) -> usize { + let mut size = 32usize; + if self.palette.is_some() { + size += 1024; + } + for level in &self.mip_levels { + size += level.size; + } + size + } +} + +pub fn parse_texm(payload: &[u8]) -> Result<Texture> { + if payload.len() < 32 { + return Err(Error::HeaderTooSmall { + size: payload.len(), + }); + } + + let magic = read_u32(payload, 0)?; + if magic != TEXM_MAGIC { + return Err(Error::InvalidMagic { got: magic }); + } + + let width = read_u32(payload, 4)?; + let height = read_u32(payload, 8)?; + let mip_count = read_u32(payload, 12)?; + let flags4 = read_u32(payload, 16)?; + let flags5 = read_u32(payload, 20)?; + let unk6 = read_u32(payload, 24)?; + let format_raw = read_u32(payload, 28)?; + + if width == 0 || height == 0 { + return Err(Error::InvalidDimensions { width, height }); + } + if mip_count == 0 { + return Err(Error::InvalidMipCount { mip_count }); + } + + let format = + PixelFormat::from_raw(format_raw).ok_or(Error::UnknownFormat { format: format_raw })?; + let bytes_per_pixel = format.bytes_per_pixel(); + + let mut offset = 32usize; + let palette = if format == PixelFormat::Indexed8 { + let end = offset.checked_add(1024).ok_or(Error::IntegerOverflow)?; + if end > payload.len() { + return Err(Error::CoreDataOutOfBounds { + expected_end: end, + actual_size: payload.len(), + }); + } + let mut pal = [0u8; 1024]; + pal.copy_from_slice(&payload[offset..end]); + offset = end; + Some(pal) + } else { + None + }; + + let mut mip_levels = + Vec::with_capacity(usize::try_from(mip_count).map_err(|_| Error::IntegerOverflow)?); + let mut w = width; + let mut h = height; + for _ in 0..mip_count { + let pixel_count_u64 = u64::from(w) + .checked_mul(u64::from(h)) + .ok_or(Error::IntegerOverflow)?; + let level_size_u64 = pixel_count_u64 + .checked_mul(u64::try_from(bytes_per_pixel).map_err(|_| Error::IntegerOverflow)?) + .ok_or(Error::IntegerOverflow)?; + let level_size = usize::try_from(level_size_u64).map_err(|_| Error::IntegerOverflow)?; + let level_offset = offset; + offset = offset + .checked_add(level_size) + .ok_or(Error::IntegerOverflow)?; + if offset > payload.len() { + return Err(Error::CoreDataOutOfBounds { + expected_end: offset, + actual_size: payload.len(), + }); + } + mip_levels.push(MipLevel { + width: w, + height: h, + offset: level_offset, + size: level_size, + }); + w = w.max(1) >> 1; + h = h.max(1) >> 1; + if w == 0 { + w = 1; + } + if h == 0 { + h = 1; + } + } + + let page_rects = parse_page_tail(payload, offset)?; + + Ok(Texture { + header: Header { + width, + height, + mip_count, + flags4, + flags5, + unk6, + format_raw, + format, + }, + palette, + mip_levels, + page_rects, + }) +} + +fn parse_page_tail(payload: &[u8], core_end: usize) -> Result<Vec<PageRect>> { + if core_end == payload.len() { + return Ok(Vec::new()); + } + if payload.len().saturating_sub(core_end) < 8 { + return Err(Error::InvalidPageSize { + expected: 8, + actual: payload.len().saturating_sub(core_end), + }); + } + let magic = read_u32(payload, core_end)?; + if magic != PAGE_MAGIC { + return Err(Error::InvalidPageMagic); + } + let rect_count = read_u32(payload, core_end + 4)?; + let rect_count_usize = usize::try_from(rect_count).map_err(|_| Error::IntegerOverflow)?; + let expected_size = 8usize + .checked_add( + rect_count_usize + .checked_mul(8) + .ok_or(Error::IntegerOverflow)?, + ) + .ok_or(Error::IntegerOverflow)?; + let actual = payload.len().saturating_sub(core_end); + if expected_size != actual { + return Err(Error::InvalidPageSize { + expected: expected_size, + actual, + }); + } + + let mut rects = Vec::with_capacity(rect_count_usize); + for i in 0..rect_count_usize { + let off = core_end + .checked_add(8) + .and_then(|v| v.checked_add(i * 8)) + .ok_or(Error::IntegerOverflow)?; + rects.push(PageRect { + x: read_i16(payload, off)?, + w: read_i16(payload, off + 2)?, + y: read_i16(payload, off + 4)?, + h: read_i16(payload, off + 6)?, + }); + } + Ok(rects) +} + +fn read_u32(data: &[u8], offset: usize) -> Result<u32> { + let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?; + let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; + Ok(u32::from_le_bytes(arr)) +} + +fn read_i16(data: &[u8], offset: usize) -> Result<i16> { + let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?; + let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; + Ok(i16::from_le_bytes(arr)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/texm/src/tests.rs b/crates/texm/src/tests.rs new file mode 100644 index 0000000..d021346 --- /dev/null +++ b/crates/texm/src/tests.rs @@ -0,0 +1,150 @@ +use super::*; +use nres::Archive; +use std::fs; +use std::path::{Path, PathBuf}; + +fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_recursive(&path, out); + } else if path.is_file() { + out.push(path); + } + } +} + +fn nres_test_files() -> Vec<PathBuf> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("testdata"); + let mut files = Vec::new(); + collect_files_recursive(&root, &mut files); + files.sort(); + files + .into_iter() + .filter(|path| { + fs::read(path) + .map(|bytes| bytes.get(0..4) == Some(b"NRes")) + .unwrap_or(false) + }) + .collect() +} + +#[test] +fn texm_parse_all_game_textures() { + let archives = nres_test_files(); + if archives.is_empty() { + eprintln!("skipping texm_parse_all_game_textures: no NRes files in testdata"); + return; + } + + let mut texm_total = 0usize; + let mut texm_with_page = 0usize; + for archive_path in archives { + let archive = Archive::open_path(&archive_path) + .unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display())); + + for entry in archive.entries() { + if entry.meta.kind != TEXM_MAGIC { + continue; + } + texm_total += 1; + let payload = archive.read(entry.id).unwrap_or_else(|err| { + panic!( + "failed to read Texm entry '{}' in {}: {err}", + entry.meta.name, + archive_path.display() + ) + }); + let texture = parse_texm(payload.as_slice()).unwrap_or_else(|err| { + panic!( + "failed to parse Texm '{}' in {}: {err}", + entry.meta.name, + archive_path.display() + ) + }); + if !texture.page_rects.is_empty() { + texm_with_page += 1; + } + + assert!( + texture.core_size() <= payload.as_slice().len(), + "core size must be within payload for '{}' in {}", + entry.meta.name, + archive_path.display() + ); + assert_eq!( + usize::try_from(texture.header.mip_count).ok(), + Some(texture.mip_levels.len()), + "mip count mismatch for '{}' in {}", + entry.meta.name, + archive_path.display() + ); + } + } + + assert!(texm_total > 0, "no Texm textures found"); + assert!( + texm_with_page > 0, + "expected at least one Texm texture with Page chunk" + ); +} + +#[test] +fn texm_parse_minimal_argb8888_no_page() { + let mut payload = Vec::new(); + payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes()); + payload.extend_from_slice(&1u32.to_le_bytes()); // width + payload.extend_from_slice(&1u32.to_le_bytes()); // height + payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count + payload.extend_from_slice(&0u32.to_le_bytes()); // flags4 + payload.extend_from_slice(&0u32.to_le_bytes()); // flags5 + payload.extend_from_slice(&0u32.to_le_bytes()); // unk6 + payload.extend_from_slice(&8888u32.to_le_bytes()); // format + payload.extend_from_slice(&[1, 2, 3, 4]); // one pixel + + let parsed = parse_texm(&payload).expect("failed to parse minimal texm"); + assert_eq!(parsed.header.width, 1); + assert_eq!(parsed.header.height, 1); + assert_eq!(parsed.mip_levels.len(), 1); + assert!(parsed.page_rects.is_empty()); +} + +#[test] +fn texm_parse_indexed_with_page_chunk() { + let mut payload = Vec::new(); + payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes()); + payload.extend_from_slice(&2u32.to_le_bytes()); // width + payload.extend_from_slice(&2u32.to_le_bytes()); // height + payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count + payload.extend_from_slice(&0u32.to_le_bytes()); // flags4 + payload.extend_from_slice(&0u32.to_le_bytes()); // flags5 + payload.extend_from_slice(&0u32.to_le_bytes()); // unk6 + payload.extend_from_slice(&0u32.to_le_bytes()); // format indexed8 + payload.extend_from_slice(&[0u8; 1024]); // palette + payload.extend_from_slice(&[1, 2, 3, 4]); // pixels + payload.extend_from_slice(&PAGE_MAGIC.to_le_bytes()); + payload.extend_from_slice(&1u32.to_le_bytes()); // rect_count + payload.extend_from_slice(&0i16.to_le_bytes()); // x + payload.extend_from_slice(&2i16.to_le_bytes()); // w + payload.extend_from_slice(&0i16.to_le_bytes()); // y + payload.extend_from_slice(&2i16.to_le_bytes()); // h + + let parsed = parse_texm(&payload).expect("failed to parse indexed texm"); + assert!(parsed.palette.is_some()); + assert_eq!(parsed.page_rects.len(), 1); + assert_eq!( + parsed.page_rects[0], + PageRect { + x: 0, + w: 2, + y: 0, + h: 2 + } + ); +} |
