#![forbid(unsafe_code)] //! Stage-3 Texm texture contract. use std::sync::Arc; const TEXM_MAGIC: u32 = 0x6D78_6554; const PAGE_MAGIC: u32 = 0x6567_6150; /// Pixel format. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PixelFormat { /// Indexed 8. Indexed8, /// RGB565. Rgb565, /// RGB556. Rgb556, /// ARGB4444. Argb4444, /// Luminance alpha 8:8. L8A8, /// RGB888 with preserved service byte in disk payload. Rgb888x, /// ARGB8888. Argb8888, } /// Texm disk document. #[derive(Clone, Debug)] pub struct TexmDocument { bytes: Arc<[u8]>, texture: Texture, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum DiskPixelFormat { Indexed8, Rgb565, Rgb556, Argb4444, L8A8, Rgb888x, Argb8888, } impl DiskPixelFormat { fn from_raw(raw: u32) -> Option { match raw { 0 => Some(Self::Indexed8), 565 => Some(Self::Rgb565), 556 => Some(Self::Rgb556), 4444 => Some(Self::Argb4444), 88 => Some(Self::L8A8), 888 => Some(Self::Rgb888x), 8888 => Some(Self::Argb8888), _ => None, } } fn bytes_per_pixel(self) -> usize { match self { Self::Indexed8 => 1, Self::Rgb565 | Self::Rgb556 | Self::Argb4444 | Self::L8A8 => 2, Self::Rgb888x | Self::Argb8888 => 4, } } } #[derive(Clone, Debug)] struct Header { width: u32, height: u32, format: DiskPixelFormat, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct MipLevel { width: u32, height: u32, offset: usize, size: usize, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct DiskPageRect { x: i16, w: i16, y: i16, h: i16, } #[derive(Clone, Debug)] struct Texture { header: Header, palette: Option<[u8; 1024]>, mip_levels: Vec, page_rects: Vec, } #[derive(Clone, Debug, Eq, PartialEq)] struct DecodedMip { width: u32, height: u32, rgba8: Vec, } /// Borrowed mip level view. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct MipLevelView<'a> { /// Mip level index. pub level: u32, /// Width. pub width: u32, /// Height. pub height: u32, /// Raw disk bytes for this level. pub bytes: &'a [u8], } /// Page rectangle. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct PageRect { /// X origin. pub x: i16, /// Width. pub w: i16, /// Y origin. pub y: i16, /// Height. pub h: i16, } /// Page rectangle scaling policy. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum PageScalePolicy { /// Scale origin with floor and end with ceil, preserving coverage. #[default] FloorOriginCeilEnd, } /// RGBA8 image. #[derive(Clone, Debug, Eq, PartialEq)] pub struct RgbaImage { /// Width. pub width: u32, /// Height. pub height: u32, /// Packed RGBA8 pixels. pub rgba8: Vec, } /// Texture upload plan. #[derive(Clone, Debug, Eq, PartialEq)] pub struct TextureUploadPlan { /// Pixel format. pub format: PixelFormat, /// Original texture width. pub width: u32, /// Original texture height. pub height: u32, /// Selected mip levels. pub mips: Vec, /// Page rectangles copied from disk metadata. pub page_rects: Vec, } /// Upload mip description. #[derive(Clone, Debug, Eq, PartialEq)] pub struct UploadMip { /// Original mip level index. pub level: u32, /// Width. pub width: u32, /// Height. pub height: u32, /// Byte offset in the original disk document. pub offset: usize, /// Byte size. pub size: usize, } /// Mip skip policy. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct MipSkipPolicy { /// Number of top mip levels to skip. pub skip_top_levels: u32, } /// Texm decode error. #[derive(Debug)] pub enum TexmError { /// Legacy parser error. Format(String), /// Requested mip level is absent. MipLevelOutOfRange { /// Requested level. requested: u32, /// Available mip count. mip_count: usize, }, /// Mip payload range is outside the document. MipDataOutOfBounds { /// Byte offset. offset: usize, /// Byte size. size: usize, /// Document size. document_size: usize, }, /// All mip levels were skipped. EmptyUploadPlan, } impl std::fmt::Display for TexmError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Format(message) => write!(f, "{message}"), Self::MipLevelOutOfRange { requested, mip_count, } => write!( f, "Texm mip level out of range: requested={requested}, mip_count={mip_count}" ), Self::MipDataOutOfBounds { offset, size, document_size, } => write!( f, "Texm mip bytes out of bounds: offset={offset}, size={size}, document_size={document_size}" ), Self::EmptyUploadPlan => write!(f, "Texm upload plan contains no mip levels"), } } } impl std::error::Error for TexmError {} /// Decodes Texm disk bytes. /// /// # Errors /// /// Returns [`TexmError`] when the header, format, mip chain, palette, or Page /// chunk is malformed. pub fn decode_texm(bytes: Arc<[u8]>) -> Result { let texture = parse_texm(&bytes)?; Ok(TexmDocument { bytes, texture }) } /// Decodes one mip level into RGBA8 using the CPU reference decoder. /// /// # Errors /// /// Returns [`TexmError`] when `level` is outside the mip chain or mip bytes are /// malformed. pub fn decode_mip_rgba8(document: &TexmDocument, level: u32) -> Result { let decoded = decode_mip_rgba8_internal( &document.texture, &document.bytes, usize::try_from(level).map_err(|_| TexmError::MipLevelOutOfRange { requested: level, mip_count: document.texture.mip_levels.len(), })?, )?; Ok(RgbaImage { width: decoded.width, height: decoded.height, rgba8: decoded.rgba8, }) } /// Builds an upload plan without mutating the disk document. /// /// # Errors /// /// Returns [`TexmError::EmptyUploadPlan`] when the policy skips every mip. pub fn plan_upload( document: &TexmDocument, policy: MipSkipPolicy, ) -> Result { let skip = usize::try_from(policy.skip_top_levels).map_err(|_| TexmError::EmptyUploadPlan)?; let mips = document .texture .mip_levels .iter() .enumerate() .skip(skip) .map(|(level, mip)| { Ok(UploadMip { level: u32::try_from(level).map_err(|_| TexmError::EmptyUploadPlan)?, width: mip.width, height: mip.height, offset: mip.offset, size: mip.size, }) }) .collect::, TexmError>>()?; if mips.is_empty() { return Err(TexmError::EmptyUploadPlan); } Ok(TextureUploadPlan { format: map_format(document.texture.header.format), width: document.texture.header.width, height: document.texture.header.height, mips, page_rects: document .texture .page_rects .iter() .copied() .map(map_page_rect) .collect(), }) } /// Returns Page rectangles scaled to a selected mip level. /// /// # Errors /// /// Returns [`TexmError`] when `level` is outside the mip chain or scaled values /// cannot be represented as `i16`. pub fn scaled_page_rects( document: &TexmDocument, level: u32, policy: PageScalePolicy, ) -> Result, TexmError> { let mip = document.mip_level(level)?; document .texture .page_rects .iter() .copied() .map(|rect| { scale_page_rect( document.width(), document.height(), mip.width, mip.height, rect, policy, ) }) .collect() } impl TexmDocument { /// Width. #[must_use] pub fn width(&self) -> u32 { self.texture.header.width } /// Height. #[must_use] pub fn height(&self) -> u32 { self.texture.header.height } /// Pixel format. #[must_use] pub fn format(&self) -> PixelFormat { map_format(self.texture.header.format) } /// Mip count. #[must_use] pub fn mip_count(&self) -> usize { self.texture.mip_levels.len() } /// Returns a borrowed mip view. /// /// # Errors /// /// Returns [`TexmError`] when `level` is outside the mip chain or the stored /// range is outside the document. pub fn mip_level(&self, level: u32) -> Result, TexmError> { let requested = usize::try_from(level).map_err(|_| TexmError::MipLevelOutOfRange { requested: level, mip_count: self.texture.mip_levels.len(), })?; let mip = self .texture .mip_levels .get(requested) .ok_or(TexmError::MipLevelOutOfRange { requested: level, mip_count: self.texture.mip_levels.len(), })?; let end = mip .offset .checked_add(mip.size) .ok_or(TexmError::MipDataOutOfBounds { offset: mip.offset, size: mip.size, document_size: self.bytes.len(), })?; let bytes = self .bytes .get(mip.offset..end) .ok_or(TexmError::MipDataOutOfBounds { offset: mip.offset, size: mip.size, document_size: self.bytes.len(), })?; Ok(MipLevelView { level, width: mip.width, height: mip.height, bytes, }) } /// Page rectangles. #[must_use] pub fn page_rects(&self) -> Vec { self.texture .page_rects .iter() .copied() .map(map_page_rect) .collect() } } fn map_format(format: DiskPixelFormat) -> PixelFormat { match format { DiskPixelFormat::Indexed8 => PixelFormat::Indexed8, DiskPixelFormat::Rgb565 => PixelFormat::Rgb565, DiskPixelFormat::Rgb556 => PixelFormat::Rgb556, DiskPixelFormat::Argb4444 => PixelFormat::Argb4444, DiskPixelFormat::L8A8 => PixelFormat::L8A8, DiskPixelFormat::Rgb888x => PixelFormat::Rgb888x, DiskPixelFormat::Argb8888 => PixelFormat::Argb8888, } } fn map_page_rect(rect: DiskPageRect) -> PageRect { PageRect { x: rect.x, w: rect.w, y: rect.y, h: rect.h, } } fn scale_page_rect( source_width: u32, source_height: u32, target_width: u32, target_height: u32, rect: DiskPageRect, policy: PageScalePolicy, ) -> Result { match policy { PageScalePolicy::FloorOriginCeilEnd => { let x0 = scale_floor(rect.x, target_width, source_width)?; let y0 = scale_floor(rect.y, target_height, source_height)?; let x1 = scale_ceil( rect.x .checked_add(rect.w) .ok_or_else(integer_overflow_error)?, target_width, source_width, )?; let y1 = scale_ceil( rect.y .checked_add(rect.h) .ok_or_else(integer_overflow_error)?, target_height, source_height, )?; Ok(PageRect { x: x0, w: checked_i16(i32::from(x1) - i32::from(x0))?, y: y0, h: checked_i16(i32::from(y1) - i32::from(y0))?, }) } } } fn scale_floor(value: i16, numerator: u32, denominator: u32) -> Result { checked_i16(div_floor( i64::from(value) * i64::from(numerator), i64::from(denominator), )?) } fn scale_ceil(value: i16, numerator: u32, denominator: u32) -> Result { checked_i16(div_ceil( i64::from(value) * i64::from(numerator), i64::from(denominator), )?) } fn div_floor(value: i64, divisor: i64) -> Result { let result = if value >= 0 { value / divisor } else { -((-value + divisor - 1) / divisor) }; i32::try_from(result).map_err(|_| integer_overflow_error()) } fn div_ceil(value: i64, divisor: i64) -> Result { let result = if value >= 0 { (value + divisor - 1) / divisor } else { -((-value) / divisor) }; i32::try_from(result).map_err(|_| integer_overflow_error()) } fn checked_i16(value: i32) -> Result { i16::try_from(value) .map_err(|_| TexmError::Format(format!("scaled Page rect value out of range: {value}"))) } fn parse_texm(payload: &[u8]) -> Result { if payload.len() < 32 { return Err(TexmError::Format(format!( "Texm payload too small for header: {}", payload.len() ))); } let magic = read_u32(payload, 0)?; if magic != TEXM_MAGIC { return Err(TexmError::Format(format!( "invalid Texm magic: 0x{magic:08X}" ))); } let width = read_u32(payload, 4)?; let height = read_u32(payload, 8)?; let mip_count = read_u32(payload, 12)?; let format_raw = read_u32(payload, 28)?; if width == 0 || height == 0 { return Err(TexmError::Format(format!( "invalid Texm dimensions: {width}x{height}" ))); } if mip_count == 0 { return Err(TexmError::Format(format!( "invalid Texm mip_count={mip_count}" ))); } let format = DiskPixelFormat::from_raw(format_raw) .ok_or_else(|| TexmError::Format(format!("unknown Texm format={format_raw}")))?; let bytes_per_pixel = format.bytes_per_pixel(); let mut offset = 32usize; let palette = if format == DiskPixelFormat::Indexed8 { let end = offset .checked_add(1024) .ok_or_else(integer_overflow_error)?; if end > payload.len() { return Err(TexmError::Format(format!( "Texm core data out of bounds: 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(|_| integer_overflow_error())?); let mut w = width; let mut h = height; for _ in 0..mip_count { let pixel_count = u64::from(w) .checked_mul(u64::from(h)) .ok_or_else(integer_overflow_error)?; let level_size_u64 = pixel_count .checked_mul(u64::try_from(bytes_per_pixel).map_err(|_| integer_overflow_error())?) .ok_or_else(integer_overflow_error)?; let level_size = usize::try_from(level_size_u64).map_err(|_| integer_overflow_error())?; let level_offset = offset; offset = offset .checked_add(level_size) .ok_or_else(integer_overflow_error)?; if offset > payload.len() { return Err(TexmError::Format(format!( "Texm core data out of bounds: expected_end={offset}, actual_size={}", payload.len() ))); } mip_levels.push(MipLevel { width: w, height: h, offset: level_offset, size: level_size, }); w = (w >> 1).max(1); h = (h >> 1).max(1); } let page_rects = parse_page_tail(payload, offset)?; Ok(Texture { header: Header { width, height, format, }, palette, mip_levels, page_rects, }) } fn decode_mip_rgba8_internal( texture: &Texture, payload: &[u8], mip_index: usize, ) -> Result { let Some(level) = texture.mip_levels.get(mip_index).copied() else { return Err(TexmError::MipLevelOutOfRange { requested: u32::try_from(mip_index).unwrap_or(u32::MAX), mip_count: texture.mip_levels.len(), }); }; let end = level .offset .checked_add(level.size) .ok_or(TexmError::MipDataOutOfBounds { offset: level.offset, size: level.size, document_size: payload.len(), })?; let Some(level_data) = payload.get(level.offset..end) else { return Err(TexmError::MipDataOutOfBounds { offset: level.offset, size: level.size, document_size: payload.len(), }); }; let width = usize::try_from(level.width).map_err(|_| integer_overflow_error())?; let height = usize::try_from(level.height).map_err(|_| integer_overflow_error())?; let pixel_count = width .checked_mul(height) .ok_or_else(integer_overflow_error)?; let mut rgba = vec![0u8; pixel_count.saturating_mul(4)]; match texture.header.format { DiskPixelFormat::Indexed8 => { let palette = texture .palette .as_ref() .ok_or_else(|| TexmError::Format("indexed Texm has no palette".to_string()))?; for (index, palette_index) in level_data.iter().copied().enumerate().take(pixel_count) { let palette_offset = usize::from(palette_index).saturating_mul(4); if palette_offset + 4 > palette.len() { continue; } let out = index.saturating_mul(4); rgba[out] = palette[palette_offset]; rgba[out + 1] = palette[palette_offset + 1]; rgba[out + 2] = palette[palette_offset + 2]; rgba[out + 3] = palette[palette_offset + 3]; } } DiskPixelFormat::Rgb565 => decode_words(level_data, pixel_count, &mut rgba, decode_rgb565), DiskPixelFormat::Rgb556 => decode_words(level_data, pixel_count, &mut rgba, decode_rgb556), DiskPixelFormat::Argb4444 => { decode_words(level_data, pixel_count, &mut rgba, decode_argb4444); } DiskPixelFormat::L8A8 => { decode_words(level_data, pixel_count, &mut rgba, decode_luminance_alpha88); } DiskPixelFormat::Rgb888x => { decode_dwords(level_data, pixel_count, &mut rgba, decode_rgb888x); } DiskPixelFormat::Argb8888 => { decode_dwords(level_data, pixel_count, &mut rgba, decode_argb8888); } } Ok(DecodedMip { width: level.width, height: level.height, rgba8: rgba, }) } fn parse_page_tail(payload: &[u8], core_end: usize) -> Result, TexmError> { if core_end == payload.len() { return Ok(Vec::new()); } if payload.len().saturating_sub(core_end) < 8 { return Err(TexmError::Format(format!( "invalid Page chunk size: expected=8, actual={}", payload.len().saturating_sub(core_end) ))); } let magic = read_u32(payload, core_end)?; if magic != PAGE_MAGIC { return Err(TexmError::Format( "Texm tail exists but Page magic is missing".to_string(), )); } let rect_count = read_u32(payload, core_end + 4)?; let rect_count_usize = usize::try_from(rect_count).map_err(|_| integer_overflow_error())?; let expected_size = 8usize .checked_add( rect_count_usize .checked_mul(8) .ok_or_else(integer_overflow_error)?, ) .ok_or_else(integer_overflow_error)?; let actual = payload.len().saturating_sub(core_end); if expected_size != actual { return Err(TexmError::Format(format!( "invalid Page chunk size: expected={expected_size}, actual={actual}" ))); } let mut rects = Vec::with_capacity(rect_count_usize); for index in 0..rect_count_usize { let offset = core_end .checked_add(8) .and_then(|value| value.checked_add(index * 8)) .ok_or_else(integer_overflow_error)?; rects.push(DiskPageRect { x: read_i16(payload, offset)?, w: read_i16(payload, offset + 2)?, y: read_i16(payload, offset + 4)?, h: read_i16(payload, offset + 6)?, }); } Ok(rects) } fn read_u32(data: &[u8], offset: usize) -> Result { let bytes = data .get(offset..offset + 4) .ok_or_else(integer_overflow_error)?; let arr: [u8; 4] = bytes.try_into().map_err(|_| integer_overflow_error())?; Ok(u32::from_le_bytes(arr)) } fn read_i16(data: &[u8], offset: usize) -> Result { let bytes = data .get(offset..offset + 2) .ok_or_else(integer_overflow_error)?; let arr: [u8; 2] = bytes.try_into().map_err(|_| integer_overflow_error())?; Ok(i16::from_le_bytes(arr)) } fn decode_words(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u16) -> [u8; 4]) { for index in 0..pixel_count { let offset = index.saturating_mul(2); let Some(bytes) = data.get(offset..offset + 2) else { break; }; let word = u16::from_le_bytes([bytes[0], bytes[1]]); let pixel = decode(word); let out = index.saturating_mul(4); rgba[out..out + 4].copy_from_slice(&pixel); } } fn decode_dwords(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u32) -> [u8; 4]) { for index in 0..pixel_count { let offset = index.saturating_mul(4); let Some(bytes) = data.get(offset..offset + 4) else { break; }; let dword = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); let pixel = decode(dword); let out = index.saturating_mul(4); rgba[out..out + 4].copy_from_slice(&pixel); } } fn expand5(value: u16) -> u8 { u8::try_from((u32::from(value) * 255 + 15) / 31).unwrap_or(u8::MAX) } fn expand6(value: u16) -> u8 { u8::try_from((u32::from(value) * 255 + 31) / 63).unwrap_or(u8::MAX) } fn expand4(value: u16) -> u8 { u8::try_from(u32::from(value) * 17).unwrap_or(u8::MAX) } fn decode_rgb565(word: u16) -> [u8; 4] { let red = expand5((word >> 11) & 0x1F); let green = expand6((word >> 5) & 0x3F); let blue = expand5(word & 0x1F); [red, green, blue, 255] } fn decode_rgb556(word: u16) -> [u8; 4] { let red = expand5((word >> 11) & 0x1F); let green = expand5((word >> 6) & 0x1F); let blue = expand6(word & 0x3F); [red, green, blue, 255] } fn decode_argb4444(word: u16) -> [u8; 4] { let alpha = expand4((word >> 12) & 0x0F); let red = expand4((word >> 8) & 0x0F); let green = expand4((word >> 4) & 0x0F); let blue = expand4(word & 0x0F); [red, green, blue, alpha] } fn decode_luminance_alpha88(word: u16) -> [u8; 4] { let luminance = u8::try_from((word >> 8) & 0xFF).unwrap_or(u8::MAX); let alpha = u8::try_from(word & 0xFF).unwrap_or(u8::MAX); [luminance, luminance, luminance, alpha] } fn decode_rgb888x(dword: u32) -> [u8; 4] { let red = u8::try_from(dword & 0xFF).unwrap_or(u8::MAX); let green = u8::try_from((dword >> 8) & 0xFF).unwrap_or(u8::MAX); let blue = u8::try_from((dword >> 16) & 0xFF).unwrap_or(u8::MAX); [red, green, blue, 255] } fn decode_argb8888(dword: u32) -> [u8; 4] { let alpha = u8::try_from(dword & 0xFF).unwrap_or(u8::MAX); let red = u8::try_from((dword >> 8) & 0xFF).unwrap_or(u8::MAX); let green = u8::try_from((dword >> 16) & 0xFF).unwrap_or(u8::MAX); let blue = u8::try_from((dword >> 24) & 0xFF).unwrap_or(u8::MAX); [red, green, blue, alpha] } fn integer_overflow_error() -> TexmError { TexmError::Format("integer overflow".to_string()) } /// Returns migration status. #[must_use] pub fn migration_facade_ready() -> bool { true } #[cfg(test)] mod tests { use super::*; use fparkan_nres::ReadProfile; use std::path::{Path, PathBuf}; const TEXM_MAGIC: u32 = 0x6D78_6554; #[test] fn decodes_all_synthetic_formats() { let cases = [ (0, PixelFormat::Indexed8, indexed_payload()), ( 565, PixelFormat::Rgb565, payload(1, 1, 565, &[&0xFFE0_u16.to_le_bytes()]), ), ( 556, PixelFormat::Rgb556, payload(1, 1, 556, &[&0xF800_u16.to_le_bytes()]), ), ( 4444, PixelFormat::Argb4444, payload(1, 1, 4444, &[&0xF12E_u16.to_le_bytes()]), ), ( 88, PixelFormat::L8A8, payload(1, 1, 88, &[&0x7F40_u16.to_le_bytes()]), ), ( 888, PixelFormat::Rgb888x, payload(1, 1, 888, &[&[0x11, 0x22, 0x33, 0x99]]), ), ( 8888, PixelFormat::Argb8888, payload(1, 1, 8888, &[&[0x40, 0x11, 0x22, 0x33]]), ), ]; for (raw, expected, bytes) in cases { let document = decode_texm(Arc::from(bytes.into_boxed_slice())) .unwrap_or_else(|err| panic!("format {raw}: {err}")); assert_eq!(document.format(), expected); assert_eq!(document.mip_count(), 1); let rgba = decode_mip_rgba8(&document, 0).unwrap_or_else(|err| panic!("format {raw}: {err}")); assert_eq!(rgba.width, 1); assert_eq!(rgba.height, 1); assert_eq!(rgba.rgba8.len(), 4); } } #[test] fn rejects_zero_dimensions() { let err = decode_texm(Arc::from( payload(0, 1, 8888, &[&[0, 0, 0, 0]]).into_boxed_slice(), )) .expect_err("zero width"); assert!(matches!(err, TexmError::Format(_))); } #[test] fn non_power_of_two_mip_chain_clamps_each_dimension() { let bytes = payload(3, 2, 8888, &[&[0; 3 * 2 * 4], &[1, 2, 3, 4], &[5, 6, 7, 8]]); let document = decode_texm(Arc::from(bytes.into_boxed_slice())).expect("document"); assert_eq!(document.mip_level(0).expect("mip 0").width, 3); assert_eq!(document.mip_level(0).expect("mip 0").height, 2); assert_eq!(document.mip_level(1).expect("mip 1").width, 1); assert_eq!(document.mip_level(1).expect("mip 1").height, 1); assert_eq!(document.mip_level(2).expect("mip 2").width, 1); assert_eq!(document.mip_level(2).expect("mip 2").height, 1); } #[test] fn rejects_mip_size_arithmetic_overflow_or_oob() { let err = decode_texm(Arc::from( header(u32::MAX, u32::MAX, 1, 8888).into_boxed_slice(), )) .expect_err("huge mip"); assert!(matches!(err, TexmError::Format(_))); } #[test] fn indexed_palette_requires_exact_1024_bytes() { let mut bytes = indexed_payload(); bytes.remove(32 + 1023); let err = decode_texm(Arc::from(bytes.into_boxed_slice())).expect_err("short palette"); assert!(matches!(err, TexmError::Format(_))); } #[test] fn channel_expansion_boundary_values_are_stable() { let document = decode_texm(Arc::from( payload(2, 1, 565, &[&[0x00, 0x00, 0xFF, 0xFF]]).into_boxed_slice(), )) .expect("rgb565 document"); let rgba = decode_mip_rgba8(&document, 0).expect("rgba"); assert_eq!(rgba.rgba8, vec![0, 0, 0, 255, 255, 255, 255, 255]); } #[test] fn rgb888x_preserves_fourth_disk_byte_but_outputs_opaque_alpha() { let document = decode_texm(Arc::from( payload(1, 1, 888, &[&[0x11, 0x22, 0x33, 0x99]]).into_boxed_slice(), )) .expect("rgb888x document"); assert_eq!( document.mip_level(0).expect("mip").bytes, &[0x11, 0x22, 0x33, 0x99] ); assert_eq!( decode_mip_rgba8(&document, 0).expect("rgba").rgba8, vec![0x11, 0x22, 0x33, 0xFF] ); } #[test] fn page_tail_absent_and_exact_rect_framing() { let absent = decode_texm(Arc::from( payload(1, 1, 8888, &[&[0, 0, 0, 0]]).into_boxed_slice(), )) .expect("page absent"); assert!(absent.page_rects().is_empty()); let mut bytes = payload(1, 1, 8888, &[&[0, 0, 0, 0]]); push_page_tail(&mut bytes, &[(1, 2, 3, 4)]); let document = decode_texm(Arc::from(bytes.into_boxed_slice())).expect("page rect"); assert_eq!( document.page_rects(), vec![PageRect { x: 1, w: 2, y: 3, h: 4, }] ); } #[test] fn invalid_page_magic_size_and_trailing_bytes_are_rejected() { let mut missing_magic = payload(1, 1, 8888, &[&[0, 0, 0, 0]]); missing_magic.extend_from_slice(b"tail"); assert!(decode_texm(Arc::from(missing_magic.into_boxed_slice())).is_err()); let mut wrong_size = payload(1, 1, 8888, &[&[0, 0, 0, 0]]); wrong_size.extend_from_slice(&PAGE_MAGIC.to_le_bytes()); wrong_size.extend_from_slice(&2_u32.to_le_bytes()); wrong_size.extend_from_slice(&[0; 8]); assert!(decode_texm(Arc::from(wrong_size.into_boxed_slice())).is_err()); } #[test] fn exposes_mip_views_and_upload_plan_without_mutating_document() { let bytes = payload(2, 1, 8888, &[&[1, 2, 3, 4, 5, 6, 7, 8], &[9, 10, 11, 12]]); let original = bytes.clone(); let document = decode_texm(Arc::from(bytes.into_boxed_slice())).expect("document"); let mip1 = document.mip_level(1).expect("mip 1"); assert_eq!(mip1.width, 1); assert_eq!(mip1.height, 1); assert_eq!(mip1.bytes, &[9, 10, 11, 12]); let plan = plan_upload(&document, MipSkipPolicy { skip_top_levels: 1 }).expect("plan"); assert_eq!(plan.mips.len(), 1); assert_eq!(plan.mips[0].level, 1); assert_eq!(&document.bytes[..], &original[..]); } #[test] fn page_scaling_uses_floor_origin_and_ceil_end_policy() { let mut bytes = payload(5, 3, 8888, &[&[0; 5 * 3 * 4], &[0; 2 * 1 * 4]]); push_page_tail(&mut bytes, &[(1, 3, 1, 2)]); let document = decode_texm(Arc::from(bytes.into_boxed_slice())).expect("document"); assert_eq!( scaled_page_rects(&document, 1, PageScalePolicy::FloorOriginCeilEnd).expect("scaled"), vec![PageRect { x: 0, w: 2, y: 0, h: 1, }] ); assert_eq!( plan_upload(&document, MipSkipPolicy { skip_top_levels: 1 }) .expect("plan") .page_rects, vec![PageRect { x: 1, w: 3, y: 1, h: 2, }] ); } #[test] fn arbitrary_texm_payloads_do_not_panic() { for len in 0..128usize { let mut bytes = vec![0xCC; len]; if len >= 4 { bytes[0..4].copy_from_slice(&TEXM_MAGIC.to_le_bytes()); } let result = std::panic::catch_unwind(|| { let _ = decode_texm(Arc::from(bytes.into_boxed_slice())); }); assert!(result.is_ok()); } } #[test] fn licensed_corpus_texm_assets_validate_and_decode_mip0() { for (corpus, expected) in [("IS", 518_usize), ("IS2", 631_usize)] { let Some(root) = corpus_root(corpus) else { continue; }; let mut count = 0usize; for path in files_under(&root) { let Ok(bytes) = std::fs::read(&path) else { continue; }; let Ok(archive) = fparkan_nres::decode( Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible, ) else { continue; }; for entry in archive .entries() .iter() .filter(|entry| entry.meta().type_id == TEXM_MAGIC) { let payload = archive.payload(entry.id()).expect("payload"); let document = decode_texm(Arc::from(payload.to_vec().into_boxed_slice())) .unwrap_or_else(|err| { panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) }); decode_mip_rgba8(&document, 0).unwrap_or_else(|err| { panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) }); count += 1; } } assert_eq!(count, expected, "{corpus} Texm count"); } } fn indexed_payload() -> Vec { let mut palette = [0_u8; 1024]; palette[4..8].copy_from_slice(&[10, 20, 30, 255]); let mut out = header(1, 1, 1, 0); out.extend_from_slice(&palette); out.push(1); out } fn payload(width: u32, height: u32, format: u32, mip_levels: &[&[u8]]) -> Vec { let mut out = header( width, height, u32::try_from(mip_levels.len()).expect("mip count"), format, ); for level in mip_levels { out.extend_from_slice(level); } out } fn header(width: u32, height: u32, mip_count: u32, format: u32) -> Vec { let mut out = Vec::new(); out.extend_from_slice(&TEXM_MAGIC.to_le_bytes()); out.extend_from_slice(&width.to_le_bytes()); out.extend_from_slice(&height.to_le_bytes()); out.extend_from_slice(&mip_count.to_le_bytes()); out.extend_from_slice(&0_u32.to_le_bytes()); out.extend_from_slice(&0_u32.to_le_bytes()); out.extend_from_slice(&0_u32.to_le_bytes()); out.extend_from_slice(&format.to_le_bytes()); out } fn push_page_tail(out: &mut Vec, rects: &[(i16, i16, i16, i16)]) { out.extend_from_slice(&PAGE_MAGIC.to_le_bytes()); out.extend_from_slice( &u32::try_from(rects.len()) .expect("rect count") .to_le_bytes(), ); for (x, w, y, h) in rects { out.extend_from_slice(&x.to_le_bytes()); out.extend_from_slice(&w.to_le_bytes()); out.extend_from_slice(&y.to_le_bytes()); out.extend_from_slice(&h.to_le_bytes()); } } fn corpus_root(name: &str) -> Option { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") .join("testdata") .join(name); root.is_dir().then_some(root) } fn files_under(root: &Path) -> Vec { let mut out = Vec::new(); let mut stack = vec![root.to_path_buf()]; while let Some(path) = stack.pop() { let Ok(read_dir) = std::fs::read_dir(path) else { continue; }; for entry in read_dir.flatten() { let path = entry.path(); if path.is_dir() { stack.push(path); } else { out.push(path); } } } out.sort(); out } }