aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-texm
diff options
context:
space:
mode:
Diffstat (limited to 'crates/fparkan-texm')
-rw-r--r--crates/fparkan-texm/Cargo.toml14
-rw-r--r--crates/fparkan-texm/src/lib.rs1187
2 files changed, 1201 insertions, 0 deletions
diff --git a/crates/fparkan-texm/Cargo.toml b/crates/fparkan-texm/Cargo.toml
new file mode 100644
index 0000000..b3171f7
--- /dev/null
+++ b/crates/fparkan-texm/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "fparkan-texm"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+
+[dev-dependencies]
+fparkan-nres = { path = "../fparkan-nres" }
+
+[lints]
+workspace = true
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<Self> {
+ 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<MipLevel>,
+ page_rects: Vec<DiskPageRect>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct DecodedMip {
+ width: u32,
+ height: u32,
+ rgba8: Vec<u8>,
+}
+
+/// 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<u8>,
+}
+
+/// 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<UploadMip>,
+ /// Page rectangles copied from disk metadata.
+ pub page_rects: Vec<PageRect>,
+}
+
+/// 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<TexmDocument, TexmError> {
+ 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<RgbaImage, TexmError> {
+ 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<TextureUploadPlan, TexmError> {
+ 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::<Result<Vec<_>, 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<Vec<PageRect>, 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<MipLevelView<'_>, 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<PageRect> {
+ 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<PageRect, TexmError> {
+ 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<i16, TexmError> {
+ checked_i16(div_floor(
+ i64::from(value) * i64::from(numerator),
+ i64::from(denominator),
+ )?)
+}
+
+fn scale_ceil(value: i16, numerator: u32, denominator: u32) -> Result<i16, TexmError> {
+ checked_i16(div_ceil(
+ i64::from(value) * i64::from(numerator),
+ i64::from(denominator),
+ )?)
+}
+
+fn div_floor(value: i64, divisor: i64) -> Result<i32, TexmError> {
+ 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<i32, TexmError> {
+ 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, TexmError> {
+ i16::try_from(value)
+ .map_err(|_| TexmError::Format(format!("scaled Page rect value out of range: {value}")))
+}
+
+fn parse_texm(payload: &[u8]) -> Result<Texture, TexmError> {
+ 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<DecodedMip, TexmError> {
+ 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<Vec<DiskPageRect>, 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<u32, TexmError> {
+ 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<i16, TexmError> {
+ 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<u8> {
+ 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<u8> {
+ 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<u8> {
+ 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<u8>, 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<PathBuf> {
+ 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<PathBuf> {
+ 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
+ }
+}