//! 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 { 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" } } // ── 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, /// De-tiled texture data in linear order. /// BCn: standard packed block data (DDS layout). /// Uncompressed: BGRA8 pixel data. pub data: Vec, } 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 { 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)?); } // Find the first TX2D texture resource let tex_entry = entries.iter() .find(|e| e.is_texture()) .ok_or(TextureError::NoTextureFound)?; // 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[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..]; let linear_data = if is_tiled { 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() }; Ok(X360Texture { width, height, format, mip_levels: mip_count, 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 { let linear_data = detile(tiled_data, width, height, format)?; Ok(X360Texture { width, height, format, mip_levels: 1, data: linear_data }) } } // ── Core de-tiling algorithm ────────────────────────────────────────────────── /// De-tile an Xbox 360 GPU texture from tiled to linear (row-major) layout. /// /// Xbox 360 stores textures in 32×32 texel macro-tiles. Within each /// macro-tile the DXT blocks (or raw pixels) are in Morton (Z-order) order. /// This function reverses that ordering for the mip-0 level. /// /// Algorithm based on Xenia's `texture_util.cc` `TileTexture()`. pub fn detile( src: &[u8], width: u32, height: u32, format: X360TextureFormat, ) -> Result, TextureError> { let block_size = format.block_size() as u32; let bpb = format.bytes_per_block(); // Texture dimensions in blocks let blocks_wide = ((width + block_size - 1) / block_size).max(1); let blocks_tall = ((height + block_size - 1) / block_size).max(1); let expected = blocks_wide as usize * blocks_tall as usize * bpb; if src.len() < expected { return Err(TextureError::BufferTooSmall { needed: expected, have: src.len() }); } let mut dst = vec![0u8; expected]; // Xbox 360 macro-tiles are always 32×32 texels → 8×8 blocks for BCn (4-texel blocks) let macro_tile_blocks = 32 / block_size; let macro_tiles_wide = (blocks_wide + macro_tile_blocks - 1) / macro_tile_blocks; let macro_tiles_tall = (blocks_tall + macro_tile_blocks - 1) / macro_tile_blocks; let blocks_per_macro_tile = (macro_tile_blocks * macro_tile_blocks) as usize; for macro_y in 0..macro_tiles_tall { for macro_x in 0..macro_tiles_wide { let macro_base = ((macro_y * macro_tiles_wide + macro_x) as usize) * blocks_per_macro_tile * bpb; for local in 0..blocks_per_macro_tile as u32 { // Decode Morton (Z-order) index → (lx, ly) within macro-tile let (lx, ly) = morton_decode(local); let block_x = macro_x * macro_tile_blocks + lx; let block_y = macro_y * macro_tile_blocks + ly; // Skip blocks outside the actual texture if block_x >= blocks_wide || block_y >= blocks_tall { continue; } let src_offset = macro_base + local as usize * bpb; let dst_offset = (block_y * blocks_wide + block_x) 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 detile_noop_for_1x1_block() { // A 4×4 DXT1 texture = exactly 1 block = 8 bytes; de-tiling is identity let src = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04]; let result = detile(&src, 4, 4, X360TextureFormat::Dxt1).unwrap(); assert_eq!(result, src); } #[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"); } }