Files
Sylpheed/crates/sylpheed-formats/src/texture.rs
MechaCat02 854fd8dfd3 fix(texture): make BC dword-swap format-aware (don't mangle DXT5A)
The dword-swap that fixes DXT1 is a property of the BC1 COLOUR block, not of
BCn in general. RE on a DXT5A (BC4) mask showed the alpha block is byte-indexed
and wants the endian swap ALONE — dword-swapping it makes it worse. The blanket
swap from the previous commit was therefore breaking the (common) DXT5A/DXN
alpha & normal masks.

Now targeted by format:
 - DXT1            : swap dwords of each 8-byte colour block (verified).
 - DXT2_3 / DXT4_5 : swap only the colour half (bytes 8..16) of each 16-byte
                     block; alpha half endian-only. TENTATIVE — these formats
                     are near-absent in the game assets, so unvalidated against
                     a clear image.
 - DXT5A / DXN     : no dword swap (endian only).

DXT1 export re-verified clean (weapon skins); 16 formats tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:13:37 +02:00

581 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Xbox 360 texture format parsing and de-tiling.
//!
//! ## XPR2 Container Layout
//!
//! ```text
//! Offset Size Field
//! 0x00 4 Magic: "XPR2"
//! 0x04 4 header_size — pixel data section starts at this file offset (e.g. 0x2800)
//! 0x08 4 data_size — size of the pixel data section (e.g. 0x8A000)
//! 0x0C 4 num_resources
//! 0x10 16*n Resource directory: n × 16-byte Xpr2ResourceEntry structs
//! [type_tag:4][data_offset:4][data_size:4][name_offset:4]
//! … … Resource descriptors (TX2D = 52-byte D3DBaseTexture2D structs)
//! 0x2800 … Pixel data section
//! ```
//!
//! ## GPUTEXTURE_FETCH_CONSTANT (GPUFC)
//!
//! Each TX2D descriptor is 52 bytes. The 6-dword (24-byte) GPUFC starts at
//! descriptor offset +0x18:
//!
//! ```text
//! GPUFC[0] (+0x18): tiled flag at bit 31, pitch at bits[23:8]
//! GPUFC[1] (+0x1C): TextureFormat at bits[5:0], base_address at bits[31:12]
//! GPUFC[2] (+0x20): width-1 at bits[12:0], height-1 at bits[25:13] (size_2d)
//! GPUFC[3] (+0x24): swizzle, filter
//! GPUFC[4] (+0x28): mip_max at bits[9:6] → mip_count = mip_max + 1
//! GPUFC[5] (+0x2C): mip_address, packed_mips, dimension
//! ```
//!
//! ## GPU Tiling
//!
//! Xbox 360 Xenos stores textures in 32×32 texel macro-tiles. Within each
//! macro-tile the DXT blocks are arranged in Morton (Z-order) order.
//! GPUFC[0] bit 31 = 1 → tiled (de-tiling required); = 0 → linear.
//!
//! ## References
//!
//! - Xenia: `src/xenia/gpu/xenos.h` (GPUTEXTUREFORMAT enum, GPUFC bitfields)
//! - Xenia: `src/xenia/gpu/texture_util.cc` (de-tiling algorithm)
use binrw::{BinRead, binread};
use thiserror::Error;
// ── Error type ───────────────────────────────────────────────────────────────
#[derive(Debug, Error)]
pub enum TextureError {
#[error("Invalid texture header magic: expected {expected:?}, got {got:?}")]
BadMagic { expected: [u8; 4], got: [u8; 4] },
#[error("No TX2D texture resource found in XPR2 file")]
NoTextureFound,
#[error("Unsupported texture format: 0x{0:02X}")]
UnsupportedFormat(u8),
#[error("Buffer too small: need {needed} bytes, have {have}")]
BufferTooSmall { needed: usize, have: usize },
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error: {0}")]
Parse(#[from] binrw::Error),
}
// ── Texture formats ───────────────────────────────────────────────────────────
/// GPUTEXTUREFORMAT values from Xenia's `xenos.h`.
///
/// These 6-bit codes live in GPUFC dword_1 bits[5:0] — NOT the old D3DFORMAT
/// codes. The mapping is:
/// k_8_8_8_8 = 6, k_DXT1 = 18, k_DXT2_3 = 19, k_DXT4_5 = 20,
/// k_DXN = 49, k_DXT5A = 59
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum X360TextureFormat {
/// A8R8G8B8 — uncompressed 32 bpp (k_8_8_8_8 = 6)
A8R8G8B8 = 6,
/// X8R8G8B8 — uncompressed 32 bpp, no alpha (k_8_8_8_8_AS_16_16_16_16 = 7)
X8R8G8B8 = 7,
/// DXT1 / BC1 — 4 bpp, 1-bit alpha (k_DXT1 = 18)
Dxt1 = 18,
/// DXT2/3 / BC2 — 8 bpp, 4-bit explicit alpha (k_DXT2_3 = 19)
Dxt3 = 19,
/// DXT4/5 / BC3 — 8 bpp, 8-bit interpolated alpha (k_DXT4_5 = 20)
Dxt5 = 20,
/// DXN / BC5 / ATI2N — two-channel normal maps (k_DXN = 49)
Dxn = 49,
/// DXT5A / BC4 / ATI1N — single alpha channel (k_DXT5A = 59)
/// Used for gloss, specular, luminance, and reflection maps in Project Sylpheed.
Dxt5A = 59,
}
impl X360TextureFormat {
pub fn from_u8(v: u8) -> Option<Self> {
match v {
6 => Some(Self::A8R8G8B8),
7 => Some(Self::X8R8G8B8),
18 => Some(Self::Dxt1),
19 => Some(Self::Dxt3),
20 => Some(Self::Dxt5),
49 => Some(Self::Dxn),
59 => Some(Self::Dxt5A),
_ => None,
}
}
/// Bytes per compressed block (4×4 texel group) or per pixel for uncompressed.
pub fn bytes_per_block(&self) -> usize {
match self {
Self::Dxt1 | Self::Dxt5A => 8,
Self::Dxt3 | Self::Dxt5 | Self::Dxn => 16,
Self::A8R8G8B8 | Self::X8R8G8B8 => 4,
}
}
/// Is this a BCn block-compressed format?
pub fn is_block_compressed(&self) -> bool {
matches!(self, Self::Dxt1 | Self::Dxt3 | Self::Dxt5 | Self::Dxn | Self::Dxt5A)
}
/// Texels per block side (4 for BCn, 1 for uncompressed).
pub fn block_size(&self) -> usize {
if self.is_block_compressed() { 4 } else { 1 }
}
}
// ── XPR2 container format ─────────────────────────────────────────────────────
/// XPR2 container header (big-endian, 16 bytes total including magic).
#[binread]
#[br(magic = b"XPR2", big)]
#[derive(Debug, Clone)]
pub struct Xpr2Header {
/// Byte offset where the pixel data section starts (= size of header region).
/// Example value: 0x2800 = 10240.
pub header_size: u32,
/// Size of the pixel data section in bytes.
/// Example value: 0x8A000 = 565248.
pub data_size: u32,
/// Number of 16-byte resource entries in the directory at offset 0x10.
pub num_resources: u32,
}
/// One 16-byte entry in the XPR2 resource directory (starts at file offset 0x10).
#[binread]
#[br(big)]
#[derive(Debug, Clone)]
pub struct Xpr2ResourceEntry {
/// ASCII type tag: b"TX2D" for 2D textures, b"XBG7" for geometry, etc.
pub type_tag: [u8; 4],
/// Byte offset of this resource's descriptor, relative to directory base 0x10.
/// Actual file offset = data_offset + 0x10.
pub data_offset: u32,
/// Size of the resource descriptor in bytes (e.g. 0x34 = 52 for TX2D).
pub descriptor_size: u32,
/// Byte offset of this resource's name string, relative to directory base 0x10.
pub name_offset: u32,
}
impl Xpr2ResourceEntry {
pub fn is_texture(&self) -> bool {
&self.type_tag == b"TX2D"
}
/// Cubemap resource (`TXCM`) — 6 faces sharing one GPUFC descriptor.
pub fn is_cubemap(&self) -> bool {
&self.type_tag == b"TXCM"
}
}
// ── Decoded texture ───────────────────────────────────────────────────────────
/// A decoded Xbox 360 texture ready for GPU upload.
///
/// After `from_xpr2()` the `data` field holds the texture in standard linear
/// (row-major) layout. BCn formats are kept as compressed block data; the GPU
/// decompresses in hardware.
#[derive(Debug, Clone)]
pub struct X360Texture {
pub width: u32,
pub height: u32,
pub format: X360TextureFormat,
pub mip_levels: u32,
/// True when the source resource was a cubemap (`TXCM`). `data` then holds
/// only face 0 (the +X face) decoded as a 2D image — enough for a preview.
pub is_cubemap: bool,
/// De-tiled texture data in linear order.
/// BCn: standard packed block data (DDS layout).
/// Uncompressed: BGRA8 pixel data.
pub data: Vec<u8>,
}
impl X360Texture {
/// Parse the first TX2D texture from an XPR2 file's raw bytes.
///
/// Pipeline:
/// 1. Parse XPR2 header + resource directory
/// 2. Locate the first TX2D entry
/// 3. Read its GPUTEXTURE_FETCH_CONSTANT (GPUFC) at descriptor +0x18
/// 4. De-tile the pixel data (if tiled) → linear layout
pub fn from_xpr2(bytes: &[u8]) -> Result<Self, TextureError> {
use std::io::Cursor;
let mut cur = Cursor::new(bytes);
// Parse header — validates "XPR2" magic, reads 3 × u32 (total 16 bytes)
let header = Xpr2Header::read(&mut cur)?;
// Resource directory begins immediately after the 16-byte header (offset 0x10)
let mut entries = Vec::new();
for _ in 0..header.num_resources {
entries.push(Xpr2ResourceEntry::read(&mut cur)?);
}
// Select a texture resource. TX2D = 2D texture; TXCM = cubemap
// (skybox / backdrop) — same 52-byte descriptor + GPUFC layout, but the
// pixel section holds 6 faces. For a preview we decode face 0.
// XPR files are frequently PACKS of many textures; XPR_RES_INDEX picks
// the Nth texture resource (default 0) for RE/browse validation.
let want = std::env::var("XPR_RES_INDEX")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(0);
let tex_entry = entries.iter()
.filter(|e| e.is_texture() || e.is_cubemap())
.nth(want)
.ok_or(TextureError::NoTextureFound)?;
let is_cubemap = tex_entry.is_cubemap();
// The descriptor is at file offset = data_offset + 0x10 (directory base)
const DIR_BASE: usize = 0x10;
let desc_file_offset = tex_entry.data_offset as usize + DIR_BASE;
// GPUFC is a 6-dword (24-byte) block at descriptor offset +0x18
let gpufc_base = desc_file_offset + 0x18;
if bytes.len() < gpufc_base + 6 * 4 {
return Err(TextureError::BufferTooSmall {
needed: gpufc_base + 24,
have: bytes.len(),
});
}
// Read one big-endian u32 at the given file offset
let be_u32 = |offset: usize| -> u32 {
u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap())
};
let gpufc0 = be_u32(gpufc_base); // +0x18
let gpufc1 = be_u32(gpufc_base + 0x04); // +0x1C
let gpufc2 = be_u32(gpufc_base + 0x08); // +0x20
let gpufc4 = be_u32(gpufc_base + 0x10); // +0x28
// GPUFC[1] bits[5:0] = GPUTEXTUREFORMAT
let fmt_code = (gpufc1 & 0x3F) as u8;
let format = X360TextureFormat::from_u8(fmt_code)
.ok_or(TextureError::UnsupportedFormat(fmt_code))?;
// GPUFC[1] bits[7:6] = endianness. X360 stores texture words byte-
// swapped; without undoing this, BCn endpoints/indices and ARGB channels
// decode to noise. (fetch-constant `endianness`, xenos.h Endian.)
let endianness = ((gpufc1 >> 6) & 0x3) as u8;
// GPUFC[1] bits[31:12] = base_address (4KB-aligned byte offset into data section)
let base_address = (gpufc1 & 0xFFFFF000) as usize;
// GPUFC[2] / size_2d: width-1 in bits[12:0], height-1 in bits[25:13]
let width = (gpufc2 & 0x1FFF) + 1;
let height = ((gpufc2 >> 13) & 0x1FFF) + 1;
// GPUFC[4]: mip_max in bits[9:6]; mip_count = mip_max + 1
let mip_count = ((gpufc4 >> 6) & 0xF) + 1;
// GPUFC[0] bit 31 = 1 → tiled memory layout (requires de-tiling)
let is_tiled = (gpufc0 >> 31) != 0;
// Pixel data for this texture starts at: header_size + base_address
let data_start = header.header_size as usize + base_address;
if bytes.len() <= data_start {
return Err(TextureError::BufferTooSmall {
needed: data_start + 1,
have: bytes.len(),
});
}
let raw_data = &bytes[data_start..];
// Debug knob: XPR_NO_DETILE=1 skips de-tiling (linear read) so the
// effect of de-tiling can be A/B-measured during RE validation.
let force_linear = std::env::var("XPR_NO_DETILE").is_ok();
let mut linear_data = if is_tiled && !force_linear {
detile(raw_data, width, height, format)?
} else {
// Linear layout — copy only the mip-0 slice
let block_size = format.block_size() as u32;
let bw = ((width + block_size - 1) / block_size).max(1);
let bh = ((height + block_size - 1) / block_size).max(1);
let needed = bw as usize * bh as usize * format.bytes_per_block();
if raw_data.len() < needed {
return Err(TextureError::BufferTooSmall { needed, have: raw_data.len() });
}
raw_data[..needed].to_vec()
};
// Undo the X360 word byte-swap so the block/pixel data is PC-standard
// little-endian. Debug knobs: XPR_NO_ENDIAN skips it; XPR_FORCE_ENDIAN=N
// overrides the mode (0..3) for A/B validation.
let endianness = std::env::var("XPR_FORCE_ENDIAN")
.ok()
.and_then(|v| v.trim().parse::<u8>().ok())
.unwrap_or(endianness);
if std::env::var("XPR_NO_ENDIAN").is_err() {
apply_endian_swap(&mut linear_data, endianness);
}
// X360 stores the BC1 *colour* block with its two 32-bit words swapped
// relative to the PC/DDS layout — the colour endpoints live in the high
// dword, the indices in the low one. The endian field (k8in16) only
// fixes byte order *within* the 16-bit words; it does not reorder the
// dwords, so without this the endpoints/indices are transposed and the
// texture decodes to noise. (Discovered by RE: de-tiled DXT1 blocks had
// coherent endpoints only in bytes[4..8].)
//
// This applies to the colour block only. The BC4-style alpha block
// (DXT5A, and the alpha half of DXT2_3/DXT4_5) is byte-indexed and needs
// the endian swap alone — RE-confirmed: dword-swapping DXT5A makes it
// *worse*. So the swap is format-targeted, not blanket.
if std::env::var("XPR_NO_BC_DWORD_SWAP").is_err() {
match format {
// Pure colour block, 8 bytes.
X360TextureFormat::Dxt1 => swap_bc_block_dwords(&mut linear_data),
// 16-byte block = [alpha 8B][colour 8B]; swap only the colour
// half. TENTATIVE: DXT2_3/DXT4_5 are near-absent in the game's
// assets, so this half-swap is unvalidated against a clear image.
X360TextureFormat::Dxt3 | X360TextureFormat::Dxt5 => {
for block in linear_data.chunks_exact_mut(16) {
swap_bc_block_dwords(&mut block[8..16]);
}
}
// BC4 / BC5 (DXT5A / DXN): alpha/normal blocks, endian swap only.
_ => {}
}
}
Ok(X360Texture { width, height, format, mip_levels: mip_count, is_cubemap, data: linear_data })
}
/// Parse a texture from already-known parameters + raw tiled data.
///
/// Use when you have reverse-engineered a container and extracted the
/// raw tiled bytes yourself.
pub fn from_raw_tiled(
tiled_data: &[u8],
width: u32,
height: u32,
format: X360TextureFormat,
) -> Result<Self, TextureError> {
let linear_data = detile(tiled_data, width, height, format)?;
Ok(X360Texture { width, height, format, mip_levels: 1, is_cubemap: false, data: linear_data })
}
}
/// Apply the Xenos texture endian swap in place, as byte permutations over the
/// data. Xbox 360 stores texture words big-endian; this converts them to the
/// PC-standard little-endian layout that BCn decoders and wgpu expect.
/// `endianness` is the fetch-constant `dword_1` bits[7:6]:
/// 0 = none, 1 = k8in16, 2 = k8in32, 3 = k16in32 (xenia `xenos.h` `Endian`).
pub fn apply_endian_swap(data: &mut [u8], endianness: u8) {
match endianness {
1 => {
// k8in16 — swap the two bytes of each 16-bit half.
for c in data.chunks_exact_mut(2) {
c.swap(0, 1);
}
}
2 => {
// k8in32 — reverse each 32-bit word.
for c in data.chunks_exact_mut(4) {
c.reverse();
}
}
3 => {
// k16in32 — swap the two 16-bit halves of each 32-bit word.
for c in data.chunks_exact_mut(4) {
c.swap(0, 2);
c.swap(1, 3);
}
}
_ => {} // kNone
}
}
/// Swap the two 32-bit dwords within each 64-bit unit of `data`, in place.
///
/// Xbox 360 stores the BC1 *colour* block with its two 32-bit words in the
/// opposite order to the PC/DDS layout, putting the colour endpoints ahead of
/// the indices. Apply to a BC1 buffer (or the colour half of a BC2/BC3 block)
/// after the byte-level endian swap. Any trailing bytes that don't fill a full
/// 8-byte group are left untouched.
pub fn swap_bc_block_dwords(data: &mut [u8]) {
for unit in data.chunks_exact_mut(8) {
// [d0 d1 d2 d3 | d4 d5 d6 d7] → [d4 d5 d6 d7 | d0 d1 d2 d3]
let (lo, hi) = unit.split_at_mut(4);
lo.swap_with_slice(hi);
}
}
// ── Core de-tiling algorithm ──────────────────────────────────────────────────
/// Macro-tile side in blocks (Xenos tiles are 32×32 *blocks*, where a "block"
/// is one BCn 4×4 group or one uncompressed texel). `texture_address.h`
/// `kTextureTileWidthHeight` / `kMacroTileWidth`.
const MACRO_TILE_BLOCKS: u32 = 32;
/// Storage pitch/height alignment, in blocks (`kStoragePitchHeightAlignmentBlocks`).
const STORAGE_ALIGN_BLOCKS: u32 = 32;
#[inline]
fn align_up(v: u32, a: u32) -> u32 {
(v + a - 1) & !(a - 1)
}
/// Byte offset of block (x, y) within an Xbox 360 2D tiled surface.
///
/// Faithful port of xenia-canary `texture_address.h::Tiled2D` + `TiledCombine`
/// (documented Xenos hardware tiling — bank/pipe/macro-tile addressing, NOT a
/// plain Morton curve). `x`/`y` and `pitch_aligned` are in block units;
/// `bpb_log2` is log2(bytes-per-block). Returns a byte offset.
#[inline]
fn tiled_2d_offset(x: i32, y: i32, pitch_aligned: u32, bpb_log2: u32) -> i32 {
let outer_blocks = ((y >> 5) * (pitch_aligned >> 5) as i32 + (x >> 5)) << 6;
let inner_blocks = (((y >> 1) & 0b111) << 3) | (x & 0b111);
let outer_inner_bytes = (outer_blocks | inner_blocks) << bpb_log2;
let bank = (y >> 4) & 0b1;
let pipe = ((x >> 3) & 0b11) ^ (((y >> 3) & 0b1) << 1);
let y_lsb = y & 1;
// TiledCombine: splice bank/pipe/y_lsb bits into the byte address.
((y_lsb << 4) | (pipe << 6) | (bank << 11))
| (outer_inner_bytes & 0b1111)
| (((outer_inner_bytes >> 4) & 0b1) << 5)
| (((outer_inner_bytes >> 5) & 0b111) << 8)
| (outer_inner_bytes >> 8 << 12)
}
/// De-tile an Xbox 360 GPU texture (mip-0) from tiled to linear (row-major)
/// layout, using the exact Xenos address formula.
///
/// `src` must hold the tiled mip-0 surface (its storage pitch/height are
/// rounded up to 32 blocks, so it may be larger than the visible image). The
/// returned buffer is tightly packed linear block data (DDS layout for BCn).
pub fn detile(
src: &[u8],
width: u32,
height: u32,
format: X360TextureFormat,
) -> Result<Vec<u8>, TextureError> {
let block_size = format.block_size() as u32;
let bpb = format.bytes_per_block();
let bpb_log2 = (bpb as u32).trailing_zeros();
// Visible dimensions in blocks, and the padded storage pitch/height.
let blocks_wide = ((width + block_size - 1) / block_size).max(1);
let blocks_tall = ((height + block_size - 1) / block_size).max(1);
let pitch_aligned = align_up(blocks_wide, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
let height_aligned = align_up(blocks_tall, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
// The tiled surface occupies pitch_aligned × height_aligned blocks.
let src_needed = pitch_aligned as usize * height_aligned as usize * bpb;
if src.len() < src_needed {
return Err(TextureError::BufferTooSmall { needed: src_needed, have: src.len() });
}
let dst_len = blocks_wide as usize * blocks_tall as usize * bpb;
let mut dst = vec![0u8; dst_len];
for by in 0..blocks_tall {
for bx in 0..blocks_wide {
let src_offset = tiled_2d_offset(bx as i32, by as i32, pitch_aligned, bpb_log2) as usize;
let dst_offset = (by * blocks_wide + bx) as usize * bpb;
if src_offset + bpb <= src.len() && dst_offset + bpb <= dst.len() {
dst[dst_offset..dst_offset + bpb]
.copy_from_slice(&src[src_offset..src_offset + bpb]);
}
}
}
Ok(dst)
}
/// Decode a Morton (Z-order curve) index into (x, y) coordinates.
///
/// Morton encoding interleaves bits: index = …y2 x2 y1 x1 y0 x0
#[inline]
pub fn morton_decode(index: u32) -> (u32, u32) {
let x = compact_bits(index);
let y = compact_bits(index >> 1);
(x, y)
}
/// Extract every other bit and pack them into the low bits.
/// Used by `morton_decode` to de-interleave X and Y.
#[inline]
fn compact_bits(mut x: u32) -> u32 {
x &= 0x5555_5555; // keep even-position bits
x = (x ^ (x >> 1)) & 0x3333_3333;
x = (x ^ (x >> 2)) & 0x0f0f_0f0f;
x = (x ^ (x >> 4)) & 0x00ff_00ff;
x = (x ^ (x >> 8)) & 0x0000_ffff;
x
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn morton_decode_corners() {
assert_eq!(morton_decode(0), (0, 0));
assert_eq!(morton_decode(1), (1, 0)); // bit 0 → x
assert_eq!(morton_decode(2), (0, 1)); // bit 1 → y
assert_eq!(morton_decode(3), (1, 1));
}
#[test]
fn tiled_offset_origin_is_zero() {
// Block (0,0) always maps to byte offset 0 for any pitch / bpb.
assert_eq!(tiled_2d_offset(0, 0, 32, 3), 0);
assert_eq!(tiled_2d_offset(0, 0, 64, 4), 0);
}
#[test]
fn detile_single_block_reads_offset_zero() {
// A 4×4 DXT1 texture = 1 visible block, but Xenos pads the tiled
// surface to 32×32 blocks (8192 bytes). Block (0,0) sits at offset 0,
// so the de-tiled output equals the first 8 source bytes.
let mut src = vec![0u8; 32 * 32 * 8];
src[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04]);
let result = detile(&src, 4, 4, X360TextureFormat::Dxt1).unwrap();
assert_eq!(result, &src[..8]);
}
#[test]
fn swap_bc_dwords_swaps_each_64bit_half() {
// One 8-byte BC1 block: X360 stores it as [indices][endpoints]; the swap
// must move the endpoint dword to the front so BC decoders find it.
let mut one = vec![0, 1, 2, 3, 4, 5, 6, 7];
swap_bc_block_dwords(&mut one);
assert_eq!(one, vec![4, 5, 6, 7, 0, 1, 2, 3]);
// A 16-byte BC3 block = two independent 8-byte halves; each is swapped.
let mut two: Vec<u8> = (0..16).collect();
swap_bc_block_dwords(&mut two);
assert_eq!(
two,
vec![4, 5, 6, 7, 0, 1, 2, 3, 12, 13, 14, 15, 8, 9, 10, 11]
);
// Applying it twice is the identity (it's its own inverse).
let mut back = two.clone();
swap_bc_block_dwords(&mut back);
assert_eq!(back, (0..16).collect::<Vec<u8>>());
}
#[test]
fn x360_format_bytes_per_block() {
assert_eq!(X360TextureFormat::Dxt1.bytes_per_block(), 8);
assert_eq!(X360TextureFormat::Dxt5A.bytes_per_block(), 8);
assert_eq!(X360TextureFormat::Dxt5.bytes_per_block(), 16);
assert_eq!(X360TextureFormat::A8R8G8B8.bytes_per_block(), 4);
}
#[test]
fn format_from_u8_roundtrip() {
for code in [6u8, 7, 18, 19, 20, 49, 59] {
assert!(X360TextureFormat::from_u8(code).is_some(), "missing format {code}");
}
assert!(X360TextureFormat::from_u8(0x52).is_none(), "old D3DFORMAT 0x52 must not match");
}
}