Files
Syplheed-Reborn/crates/sylpheed-formats/src/texture.rs
MechaCat02 ef6e448268 feat(formats,viewer): decode XBG7 meshes + 3D model preview
Reverse-engineer the single-stream XBG7 geometry layout (clean-room: hex
inspection + geometric validation of the retail disc's
hidden/resource3d/*.xpr, no game code copied) and present models as
textured 3D meshes in the explorer.

Format (docs/re/structures/xbg7-mesh.md): XBG7 geometry resources sit
inside XPR2 containers alongside TX2D textures. For ~25 single-stream
models (weapons, simple props) the data section is a sequence of
sub-meshes, each an u16-BE triangle-list index buffer followed (after a
fixed 12-byte header) by a stride-24 vertex buffer whose declaration is
in the descriptor: POSITION f32x3 @0x00, NORMAL f16x4 @0x0C, TEXCOORD
f16x2 @0x14. Sub-mesh (vtx,idx) counts come from descriptor tuples.

The +12 vertex offset is pinned by the recovered normals being exactly
unit-length (align16 lands 4 bytes early and silently corrupts every
field). A safety gate rejects any model whose indices are out of range
or whose mean |normal| is not ~1, declining garbage (Stage_S*
placeholders, the complex multi-stream hero-ship body) rather than
mis-decoding it.

- mesh.rs: Xbg7Model::from_xpr2 -> GameMesh { positions, normals, uvs,
  indices }; standalone f16->f32; unit + real-disc tests (weapon decodes
  to 215v/364t with unit normals + in-range UVs; DeltaSaber body
  declined).
- texture.rs: from_xpr2_index / texture_names so the viewer can pick a
  model's _col albedo map.
- viewer: loose .xpr with decodable XBG7 spawns Bevy meshes (real normals,
  double-sided) textured with the albedo, framed by the orbit camera; the
  central egui panel goes transparent so the 3D scene shows through.

Complex multi-stream body meshes (DeltaSaber f004, other vertex layouts)
remain undecoded and are cleanly declined — next target is dynamic RE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:47:06 +02:00

722 lines
29 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>,
}
/// A decoded Xbox 360 cubemap (`TXCM`) — a world skybox. The 6 faces are each a
/// de-tiled 2D surface in the same layout as [`X360Texture::data`], in D3D9
/// `D3DCUBEMAP_FACES` order.
#[derive(Debug, Clone)]
pub struct Cubemap {
pub width: u32,
pub height: u32,
pub format: X360TextureFormat,
/// Exactly 6 faces, D3D9 order: +X, -X, +Y, -Y, +Z, -Z.
pub faces: Vec<Vec<u8>>,
}
impl Cubemap {
/// The D3D9 cube-face name for slice `i` (0..6).
pub fn face_label(i: usize) -> &'static str {
["+X", "-X", "+Y", "-Y", "+Z", "-Z"].get(i).copied().unwrap_or("?")
}
}
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> {
// 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);
Self::from_xpr2_index(bytes, want)
}
/// List the names of the texture resources (`TX2D` / `TXCM`) in an XPR2
/// container, in directory order — the same order [`from_xpr2_index`]
/// selects by. Non-texture resources (e.g. `XBG7`) are skipped.
pub fn texture_names(bytes: &[u8]) -> Vec<String> {
use std::io::Cursor;
let mut cur = Cursor::new(bytes);
let Ok(header) = Xpr2Header::read(&mut cur) else {
return Vec::new();
};
let mut names = Vec::new();
for _ in 0..header.num_resources {
let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else {
break;
};
if e.is_texture() || e.is_cubemap() {
const DIR_BASE: usize = 0x10;
let no = e.name_offset as usize + DIR_BASE;
let name = bytes
.get(no..)
.and_then(|s| s.iter().position(|&c| c == 0).map(|p| &s[..p]))
.map(|s| String::from_utf8_lossy(s).into_owned())
.unwrap_or_default();
names.push(name);
}
}
names
}
/// Decode the `want`-th texture resource (`TX2D` / `TXCM`, directory order).
pub fn from_xpr2_index(bytes: &[u8], want: usize) -> 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.
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(),
});
}
// De-tile + endian-correct the single (face-0) surface.
let data = decode_surface(&bytes[data_start..], width, height, format, is_tiled, endianness)?;
Ok(X360Texture { width, height, format, mip_levels: mip_count, is_cubemap, data })
}
/// Decode all 6 faces of a cubemap (`TXCM`) resource, or `Ok(None)` if the
/// selected resource is an ordinary 2D texture.
///
/// X360 cubemaps store the 6 faces back-to-back in the pixel section, each a
/// full independently-tiled 2D surface whose stride is the tiled surface size
/// rounded up to a 4 KiB subresource boundary (`kTextureSubresourceAlignmentBytes`).
/// Faces are in D3D9 `D3DCUBEMAP_FACES` order: +X, -X, +Y, -Y, +Z, -Z.
/// (Derived from xenia `texture_util.cc::GetGuestTextureLayout`; verified on
/// `BG_Acheron`: `data_size == 6 × 0x400000` and all 6 faces decode cleanly.)
pub fn cube_faces_from_xpr2(bytes: &[u8]) -> Result<Option<Cubemap>, TextureError> {
use std::io::Cursor;
let mut cur = Cursor::new(bytes);
let header = Xpr2Header::read(&mut cur)?;
let mut entries = Vec::new();
for _ in 0..header.num_resources {
entries.push(Xpr2ResourceEntry::read(&mut cur)?);
}
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)?;
if !tex_entry.is_cubemap() {
return Ok(None); // ordinary 2D texture — use `from_xpr2`
}
const DIR_BASE: usize = 0x10;
let gpufc_base = tex_entry.data_offset as usize + DIR_BASE + 0x18;
if bytes.len() < gpufc_base + 6 * 4 {
return Err(TextureError::BufferTooSmall { needed: gpufc_base + 24, have: bytes.len() });
}
let be_u32 = |o: usize| u32::from_be_bytes(bytes[o..o + 4].try_into().unwrap());
let gpufc0 = be_u32(gpufc_base);
let gpufc1 = be_u32(gpufc_base + 0x04);
let gpufc2 = be_u32(gpufc_base + 0x08);
let fmt_code = (gpufc1 & 0x3F) as u8;
let format = X360TextureFormat::from_u8(fmt_code)
.ok_or(TextureError::UnsupportedFormat(fmt_code))?;
let endianness = ((gpufc1 >> 6) & 0x3) as u8;
let base_address = (gpufc1 & 0xFFFFF000) as usize;
let width = (gpufc2 & 0x1FFF) + 1;
let height = ((gpufc2 >> 13) & 0x1FFF) + 1;
let is_tiled = (gpufc0 >> 31) != 0;
let data_start = header.header_size as usize + base_address;
let stride = tiled_face_stride(width, height, format);
let mut faces = Vec::with_capacity(6);
for f in 0..6 {
let start = data_start + f * stride;
let raw = bytes
.get(start..)
.ok_or(TextureError::BufferTooSmall { needed: start + 1, have: bytes.len() })?;
faces.push(decode_surface(raw, width, height, format, is_tiled, endianness)?);
}
Ok(Some(Cubemap { width, height, format, faces }))
}
/// 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
}
}
/// De-tile (if tiled) + endian-correct one texture surface into linear PC
/// layout. Shared by `from_xpr2` (face 0) and `cube_faces_from_xpr2` (6 faces).
/// The `XPR_NO_DETILE` / `XPR_NO_ENDIAN` / `XPR_FORCE_ENDIAN` / `XPR_NO_BC_DWORD_SWAP`
/// debug knobs are honoured here so both paths behave identically.
fn decode_surface(
raw_data: &[u8],
width: u32,
height: u32,
format: X360TextureFormat,
is_tiled: bool,
endianness: u8,
) -> Result<Vec<u8>, TextureError> {
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 block/pixel data is PC little-endian.
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);
}
// BC1 colour-block dword swap (see the long note where this was discovered):
// colour endpoints live in the high dword on X360; format-targeted so BC4/BC5
// (byte-indexed alpha/normal blocks) are left as endian-only.
if std::env::var("XPR_NO_BC_DWORD_SWAP").is_err() {
match format {
X360TextureFormat::Dxt1 => swap_bc_block_dwords(&mut linear_data),
X360TextureFormat::Dxt3 | X360TextureFormat::Dxt5 => {
for block in linear_data.chunks_exact_mut(16) {
swap_bc_block_dwords(&mut block[8..16]);
}
}
_ => {}
}
}
Ok(linear_data)
}
/// Byte stride between consecutive cubemap faces: the tiled surface size
/// (`pitch_aligned × height_aligned × bpb`, both padded to 32-block macro tiles)
/// rounded up to the 4 KiB subresource alignment (`kTextureSubresourceAlignmentBytes`).
fn tiled_face_stride(width: u32, height: u32, format: X360TextureFormat) -> usize {
let bs = format.block_size() as u32;
let bw = ((width + bs - 1) / bs).max(1);
let bh = ((height + bs - 1) / bs).max(1);
let pitch_aligned = align_up(bw, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
let height_aligned = align_up(bh, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
let surface = pitch_aligned as usize * height_aligned as usize * format.bytes_per_block();
(surface + 0xFFF) & !0xFFF
}
/// 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 cubemap_face_stride_and_labels() {
// Acheron: 1024×1024 A8R8G8B8 → 1024*1024*4 = 4 MiB, already 4 KiB-aligned.
// Verified against the real file: data_size == 6 × 0x400000.
assert_eq!(
tiled_face_stride(1024, 1024, X360TextureFormat::A8R8G8B8),
0x400000
);
// A tiny surface still occupies a full 32×32-block tile, 4 KiB-aligned.
assert_eq!(tiled_face_stride(4, 4, X360TextureFormat::A8R8G8B8), 0x1000);
assert_eq!(Cubemap::face_label(0), "+X");
assert_eq!(Cubemap::face_label(2), "+Y");
assert_eq!(Cubemap::face_label(5), "-Z");
}
#[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");
}
}