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>
581 lines
23 KiB
Rust
581 lines
23 KiB
Rust
//! 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");
|
||
}
|
||
}
|