ifstools/rust/dxt.rs
2026-04-26 13:34:08 +10:00

100 lines
3.3 KiB
Rust

//! DXT1 / DXT5 decoders for Konami's byte-swapped texture format.
//!
//! Konami stores standard DXT-compressed pixel data with each 16-bit word's
//! bytes swapped. Standard DDS/PIL/texpresso expect the canonical little-endian
//! layout, so we un-swap before handing the bytes off to texpresso.
use texpresso::Format;
#[derive(Debug)]
pub enum DxtError {
UnknownFormat(String),
OddByteCount(usize),
}
impl std::fmt::Display for DxtError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DxtError::UnknownFormat(s) => write!(f, "unknown DXT format: {}", s),
DxtError::OddByteCount(n) => write!(f, "input length {} is not a multiple of 2", n),
}
}
}
impl std::error::Error for DxtError {}
fn parse_format(s: &str) -> Result<(Format, &'static str), DxtError> {
match s {
"dxt1" | "DXT1" | "bc1" => Ok((Format::Bc1, "DXT1")),
"dxt3" | "DXT3" | "bc2" => Ok((Format::Bc2, "DXT3")),
"dxt5" | "DXT5" | "bc3" => Ok((Format::Bc3, "DXT5")),
other => Err(DxtError::UnknownFormat(other.to_string())),
}
}
/// Decode Konami-format DXT data into raw RGBA8 pixels (`width * height * 4`
/// bytes). Pads short input with zero bytes to match the prior Python
/// behaviour, which warns and continues rather than failing on truncated
/// textures.
pub fn decode(
data: &[u8],
width: usize,
height: usize,
format: &str,
) -> Result<Vec<u8>, DxtError> {
let (fmt, fmt_name) = parse_format(format)?;
if data.len() % 2 != 0 {
return Err(DxtError::OddByteCount(data.len()));
}
let expected = fmt.compressed_size(width, height);
// Konami's per-WORD byte swap, with zero padding if the source is short.
// n is always even: data.len() is checked above, expected is a DXT block size.
let mut swapped = vec![0u8; expected];
let n = data.len().min(expected);
swapped[..n].copy_from_slice(&data[..n]);
for chunk in swapped[..n].chunks_exact_mut(2) {
chunk.swap(0, 1);
}
let _ = fmt_name; // currently only used for error formatting
let mut rgba = vec![0u8; width * height * 4];
fmt.decompress(&swapped, width, height, &mut rgba);
Ok(rgba)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dxt5_smoke() {
// Single 4x4 DXT5 block: alpha 0xFF, RGB midgray.
// Standard DXT5 layout (little-endian within blocks):
// alpha0 alpha1 [6 bytes alpha indices] [color block 8 bytes]
// We Konami-swap it (per u16) before feeding to decode().
let canonical = [
0xFFu8, 0xFF, // both alpha endpoints = 255
0, 0, 0, 0, 0, 0, // alpha indices (all zero → endpoint 0 = 255 everywhere)
0xFF, 0x7F, 0xFF, 0x7F, // both colors = 0x7FFF (ish gray)
0, 0, 0, 0,
];
let mut konami = canonical;
for chunk in konami.chunks_exact_mut(2) {
chunk.swap(0, 1);
}
let rgba = decode(&konami, 4, 4, "dxt5").unwrap();
assert_eq!(rgba.len(), 4 * 4 * 4);
// Alpha should be 0xFF everywhere.
for px in rgba.chunks_exact(4) {
assert_eq!(px[3], 0xFF, "alpha not opaque: {:?}", px);
}
}
#[test]
fn unknown_format_errors() {
assert!(decode(&[0; 16], 4, 4, "garbage").is_err());
}
}