From d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Mon, 22 Jun 2026 13:12:27 +0400 Subject: feat: implement FParkan architecture foundation Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation. --- crates/fparkan-texm/src/lib.rs | 1187 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1187 insertions(+) create mode 100644 crates/fparkan-texm/src/lib.rs (limited to 'crates/fparkan-texm/src') diff --git a/crates/fparkan-texm/src/lib.rs b/crates/fparkan-texm/src/lib.rs new file mode 100644 index 0000000..6adc8b1 --- /dev/null +++ b/crates/fparkan-texm/src/lib.rs @@ -0,0 +1,1187 @@ +#![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 + } +} -- cgit v1.2.3