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>
This commit is contained in:
MechaCat02
2026-07-10 07:18:37 +02:00
parent 5dd526c1de
commit ce9fe08bec
2 changed files with 232 additions and 204 deletions

View File

@@ -1,16 +1,43 @@
//! Xbox 360 texture format parsing and de-tiling.
//!
//! ## The Problem: GPU Tiling
//! The Xbox 360 Xenos GPU stores textures in a "tiled" memory layout for
//! cache efficiency. The tiles are 32×32 texel macro-tiles, and within
//! each macro-tile the DXT compression blocks are arranged in Morton
//! (Z-order curve) order. Before we can upload a texture to a modern GPU,
//! we must "de-tile" it back to a standard linear (row-major) layout.
//! ## XPR2 Container Layout
//!
//! ## Reference implementations
//! - Xenia emulator: `src/xenia/gpu/texture_util.cc`
//! - RareView (C#): Xbox 360 texture de-tiling
//! - swizzleinator crate: general console texture unswizzling
//! ```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;
@@ -22,6 +49,9 @@ 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),
@@ -37,56 +67,58 @@ pub enum TextureError {
// ── Texture formats ───────────────────────────────────────────────────────────
/// D3DFORMAT values used by the Xbox 360 SDK for texture data.
/// These appear in XPR2 headers and in-memory texture descriptors.
/// GPUTEXTUREFORMAT values from Xenia's `xenos.h`.
///
/// Format codes sourced from:
/// - Xbox 360 SDK documentation (leaked)
/// - Xenia emulator source (gpu/xenos.h)
/// - ZenHAX community research
/// 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 {
/// DXT1 / BC1 — 4 bpp, 1-bit alpha
Dxt1 = 0x52,
/// DXT3 / BC2 — 8 bpp, 4-bit explicit alpha
Dxt3 = 0x53,
/// DXT5 / BC38 bpp, 8-bit interpolated alpha
Dxt5 = 0x54,
/// DXN / BC5 / ATI2 — normal maps, two-channel
Dxn = 0x71,
/// Uncompressed A8R8G8B832 bpp
A8R8G8B8 = 0x06,
/// Uncompressed X8R8G8B8 — 32 bpp, no alpha
X8R8G8B8 = 0x07,
/// 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 / BC14 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 / BC38 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 {
0x52 => Some(Self::Dxt1),
0x53 => Some(Self::Dxt3),
0x54 => Some(Self::Dxt5),
0x71 => Some(Self::Dxn),
0x06 => Some(Self::A8R8G8B8),
0x07 => Some(Self::X8R8G8B8),
_ => None,
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).
/// For uncompressed formats, bytes per pixel instead.
/// Bytes per compressed block (4×4 texel group) or per pixel for uncompressed.
pub fn bytes_per_block(&self) -> usize {
match self {
Self::Dxt1 => 8,
Self::Dxt3 | Self::Dxt5 | Self::Dxn => 16,
Self::A8R8G8B8 | Self::X8R8G8B8 => 4,
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)
matches!(self, Self::Dxt1 | Self::Dxt3 | Self::Dxt5 | Self::Dxn | Self::Dxt5A)
}
/// Texels per block side (4 for BCn, 1 for uncompressed).
@@ -97,176 +129,160 @@ impl X360TextureFormat {
// ── XPR2 container format ─────────────────────────────────────────────────────
/// XPR2 is Microsoft's Xbox Packed Resource v2 format.
/// It stores D3D resources (textures, vertex buffers, index buffers)
/// in a single blob that can be DMA'd directly into GPU memory.
///
/// The header is big-endian (Xbox 360 is big-endian PowerPC).
/// NOTE: Project Sylpheed may use a custom container. If files don't
/// start with b"XPR2", check for game-specific magic bytes instead.
/// XPR2 container header (big-endian, 16 bytes total including magic).
#[binread]
#[br(magic = b"XPR2", big)]
#[derive(Debug, Clone)]
pub struct Xpr2Header {
/// Total file size in bytes
pub total_size: u32,
/// Size of the header section (texture data starts after this)
/// Byte offset where the pixel data section starts (= size of header region).
/// Example value: 0x2800 = 10240.
pub header_size: u32,
/// Number of resource entries in this file
/// 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,
}
/// A single resource entry within an XPR2 file.
/// Each entry describes one D3D resource (texture, buffer, etc.)
/// One 16-byte entry in the XPR2 resource directory (starts at file offset 0x10).
#[binread]
#[br(big)]
#[derive(Debug, Clone)]
pub struct Xpr2ResourceEntry {
/// Encoded type and reference count.
/// Bits [0..15] = ref_count
/// Bits [16..18] = resource type (4 = texture)
/// Bits [19..31] = flags
pub resource_type_and_flags: u32,
/// Offset of this resource's data within the XPR2 file
/// 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,
/// Reserved / unknown
pub _unknown: 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 resource_type(&self) -> u8 {
((self.resource_type_and_flags >> 16) & 0x7) as u8
}
pub fn is_texture(&self) -> bool {
self.resource_type() == 4
}
}
/// Xbox 360 D3D texture descriptor embedded in an XPR2 resource.
/// This is what the GPU register `NV097_SET_TEXTURE_FORMAT` receives.
///
/// Reference: xboxdevwiki.net/XPR
#[binread]
#[br(big)]
#[derive(Debug, Clone)]
pub struct X360TextureDesc {
/// Packed GPU texture format register
/// Bits [0..3] = DMA channel
/// Bits [4..7] = dimensionality (2 = 2D)
/// Bits [8..15] = D3DFORMAT (see X360TextureFormat)
/// Bits [16..19] = mip levels
/// Bits [20..23] = width as power-of-two: actual = 1 << value
/// Bits [24..27] = height as power-of-two: actual = 1 << value
/// Bits [28..31] = depth (for 3D textures)
pub gpu_format: u32,
/// For non-power-of-two textures, encodes actual dimensions
pub npot_size: u32,
}
impl X360TextureDesc {
pub fn format_code(&self) -> u8 {
((self.gpu_format >> 8) & 0xFF) as u8
}
pub fn format(&self) -> Option<X360TextureFormat> {
X360TextureFormat::from_u8(self.format_code())
}
pub fn mip_levels(&self) -> u32 {
(self.gpu_format >> 16) & 0xF
}
/// Width from the packed power-of-two field.
pub fn width_pow2(&self) -> u32 {
1 << ((self.gpu_format >> 20) & 0xF)
}
/// Height from the packed power-of-two field.
pub fn height_pow2(&self) -> u32 {
1 << ((self.gpu_format >> 24) & 0xF)
&self.type_tag == b"TX2D"
}
}
// ── Decoded texture ───────────────────────────────────────────────────────────
/// A decoded Xbox 360 texture, ready to upload to a modern GPU.
/// A decoded Xbox 360 texture ready for GPU upload.
///
/// After `from_xpr2()` or `from_raw_tiled()`, the `data` field contains
/// the texture in standard linear layout that Bevy / wgpu can consume.
/// 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 (row-major) order.
/// For BCn formats: standard DDS-style packed block data.
/// For ARGB: standard RGBA8 pixel data.
/// 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 a texture from a raw XPR2 file's bytes.
/// Parse the first TX2D texture from an XPR2 file's raw bytes.
///
/// This handles the full pipeline:
/// 1. Parse XPR2 header + resource descriptors
/// 2. Locate the texture resource
/// 3. De-tile the GPU memory layout → linear layout
/// 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 main header (validates "XPR2" magic)
// Parse header validates "XPR2" magic, reads 3 × u32 (total 16 bytes)
let header = Xpr2Header::read(&mut cur)?;
// Parse resource entries
// 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 texture resource
// Find the first TX2D texture resource
let tex_entry = entries.iter()
.find(|e| e.is_texture())
.ok_or_else(|| TextureError::UnsupportedFormat(0))?;
.ok_or(TextureError::NoTextureFound)?;
// The texture descriptor immediately follows the resource entries
let desc = X360TextureDesc::read(&mut cur)?;
// 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;
let format = desc.format()
.ok_or(TextureError::UnsupportedFormat(desc.format_code()))?;
// 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(),
});
}
let width = desc.width_pow2();
let height = desc.height_pow2();
// 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())
};
// Texture data is at header_size offset
let data_start = header.header_size as usize;
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 tiled_data = &bytes[data_start..];
let raw_data = &bytes[data_start..];
// De-tile!
let linear_data = detile(tiled_data, width, height, format)?;
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: desc.mip_levels().max(1),
data: linear_data,
})
Ok(X360Texture { width, height, format, mip_levels: mip_count, data: linear_data })
}
/// Parse a texture from already-known parameters + raw tiled data.
///
/// Use this when you've reverse-engineered a game-specific container
/// and extracted the raw tiled texture bytes yourself.
/// 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,
@@ -274,30 +290,19 @@ impl X360Texture {
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,
})
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 layout.
/// De-tile an Xbox 360 GPU texture from tiled to linear (row-major) layout.
///
/// Xbox 360 stores textures in a hierarchical tiled format:
/// - The texture is divided into 32×32 texel **macro-tiles**
/// - Within each macro-tile, DXT blocks are in **Morton (Z-order)** order
/// 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.
///
/// This is required for ALL textures regardless of whether they are
/// DXT-compressed or uncompressed — the GPU expects tiled memory.
///
/// Algorithm based on:
/// - Xenia: `texture_util.cc` `TileTexture()`
/// - GTA IV Xbox 360 Texture Editor by Pimpin Tyler and Anthony
/// Algorithm based on Xenia's `texture_util.cc` `TileTexture()`.
pub fn detile(
src: &[u8],
width: u32,
@@ -305,25 +310,21 @@ pub fn detile(
format: X360TextureFormat,
) -> Result<Vec<u8>, TextureError> {
let block_size = format.block_size() as u32;
let bpb = format.bytes_per_block(); // bytes per block
let bpb = format.bytes_per_block();
// Dimensions in blocks
let blocks_wide = ((width + block_size - 1) / block_size).max(1);
// 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(),
});
return Err(TextureError::BufferTooSmall { needed: expected, have: src.len() });
}
let mut dst = vec![0u8; expected];
// Macro-tile dimensions (in blocks)
// Xbox 360 always uses 32×32 texel macro-tiles
let macro_tile_blocks = 32 / block_size; // = 8 for BCn (4×4 texels/block)
// 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;
@@ -342,7 +343,7 @@ pub fn detile(
let block_x = macro_x * macro_tile_blocks + lx;
let block_y = macro_y * macro_tile_blocks + ly;
// Skip blocks that fall outside the actual texture dimensions
// Skip blocks outside the actual texture
if block_x >= blocks_wide || block_y >= blocks_tall {
continue;
}
@@ -363,10 +364,7 @@ pub fn detile(
/// Decode a Morton (Z-order curve) index into (x, y) coordinates.
///
/// Morton encoding interleaves the bits of x and y coordinates:
/// index = ...y3 x3 y2 x2 y1 x1 y0 x0
///
/// This is the inverse operation — extract interleaved bits back to x, y.
/// 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);
@@ -374,15 +372,15 @@ pub fn morton_decode(index: u32) -> (u32, u32) {
(x, y)
}
/// Compact every other bit — the "de-interleave" operation.
/// Used by `morton_decode` to separate X and Y from a Morton index.
/// 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; // x = -f-e -d-c -b-a -9-8 -7-6 -5-4 -3-2 -1-0
x = (x ^ (x >> 1)) & 0x3333_3333; // x = --fe --dc --ba --98 --76 --54 --32 --10
x = (x ^ (x >> 2)) & 0x0f0f_0f0f; // x = ----fedc ----ba98 ----7654 ----3210
x = (x ^ (x >> 4)) & 0x00ff_00ff; // x = --------fedcba98 --------76543210
x = (x ^ (x >> 8)) & 0x0000_ffff; // x = ----------------fedcba9876543210
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
}
@@ -394,20 +392,15 @@ mod tests {
#[test]
fn morton_decode_corners() {
// Index 0 → (0, 0)
assert_eq!(morton_decode(0), (0, 0));
// Index 1 → (1, 0) — bit 0 is x
assert_eq!(morton_decode(1), (1, 0));
// Index 2 → (0, 1) — bit 1 is y
assert_eq!(morton_decode(2), (0, 1));
// Index 3 → (1, 1)
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 a single block should be identity
// 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);
@@ -415,8 +408,17 @@ mod tests {
#[test]
fn x360_format_bytes_per_block() {
assert_eq!(X360TextureFormat::Dxt1.bytes_per_block(), 8);
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");
}
}

View File

@@ -14,25 +14,48 @@ use std::path::Path;
use anyhow::{Context, Result};
use tracing::{debug, info};
use xdvdfs::blockdev::OffsetWrapper;
use xdvdfs::layout::VolumeDescriptor;
/// A handle to an open XISO image.
pub struct XisoReader<F: Read + Seek> {
///
/// The inner file is wrapped in an [`OffsetWrapper`] which probes the four
/// known XGD partition offsets (raw XISO, XGD1, XGD2, XGD3) at open time,
/// so both single-layer raw dumps **and** full dual-layer disc images are
/// supported transparently.
pub struct XisoReader<F: Read + Seek + Send + Sync> {
volume: VolumeDescriptor,
file: F,
/// Offset-aware block device — all sector reads are shifted by the
/// detected partition start offset automatically.
file: OffsetWrapper<F, std::io::Error>,
}
impl<F: Read + Seek + Send + Sync + 'static> XisoReader<F> {
pub async fn open(mut file: F) -> Result<Self> {
let volume = xdvdfs::read::read_volume(&mut file)
pub async fn open(file: F) -> Result<Self> {
// OffsetWrapper::new probes four known partition offsets:
// 0x00000000 — raw XISO (trimmed, sector 0 = XDVDFS start)
// 0x183E0000 — XGD1 (original Xbox)
// 0x0FD90000 — XGD2 (Xbox 360, most retail titles)
// 0x02080000 — XGD3 (Xbox 360, later dual-layer titles)
let mut wrapper = OffsetWrapper::new(file).await.map_err(|e| {
anyhow::anyhow!(
"No valid XDVDFS partition found in this disc image. \
Tried raw XISO, XGD1, XGD2, and XGD3 offsets. \
Is this a valid Xbox 360 (or original Xbox) disc image? \
(internal error: {e:?})"
)
})?;
// Re-read the volume descriptor via the wrapper (now at the correct offset).
let volume = xdvdfs::read::read_volume(&mut wrapper)
.await
.context("Failed to read XDVDFS volume descriptor. Is this a valid Xbox 360 ISO?")?;
.context("Found XDVDFS partition but failed to parse volume descriptor")?;
info!(
"Opened XISO: root directory table at sector {}",
{ let s = volume.root_table.region.sector; s }
);
Ok(Self { volume, file })
Ok(Self { volume, file: wrapper })
}
/// List all files in the disc image, recursively (directories excluded).
@@ -135,10 +158,13 @@ impl<F: Read + Seek + Send + Sync + 'static> XisoReader<F> {
}
}
/// Open an XISO from a file path (the common case).
/// Open a disc image from a file path.
///
/// Accepts raw XISO dumps and full XGD1/XGD2/XGD3 disc images — the correct
/// partition offset is detected automatically.
pub async fn open_iso(path: &Path) -> Result<XisoReader<std::fs::File>> {
let file = std::fs::File::open(path)
.with_context(|| format!("Cannot open ISO: {}", path.display()))?;
.with_context(|| format!("Cannot open disc image: {}", path.display()))?;
XisoReader::open(file).await
}