2026-06-22 13:12:27 +04:00
|
|
|
#![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]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-06-22 13:12:27 +04:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|