Files
Syplheed-Reborn/crates/sylpheed-formats/src/texture.rs
MechaCat02 ce9fe08bec refactor(formats): rework X360 texture descriptor + xiso reader
WIP: restructure texture.rs (X360TextureDesc accessors / decode path) and
adjust the xiso reader. Builds clean (sylpheed-formats).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 07:18:37 +02:00

425 lines
16 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"
}
}
// ── 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<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)?);
}
// 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<Self, TextureError> {
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<Vec<u8>, 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");
}
}