fix(texture): correct Xenos de-tile + endian + cubemaps + PNG export
Reworks the XPR2 texture path after RE against the retail disc (viewer previews were scrambled/failed). - de-tile: replace the naive Morton-over-32×32 approximation with the faithful Xenos Tiled2D bank/pipe/macro-tile address formula (ported from xenia texture_address.h). Verified correct: the Acheron backdrop decodes to a sharp planet with its spiral storm. - endian: undo the X360 word byte-swap per the fetch-constant endianness field (k8in16/k8in32/k16in32) — without it BCn/ARGB data is noise. - cubemaps: parse TXCM resources (skybox/backdrops), decoding face 0, instead of erroring "No TX2D found". - A8R8G8B8: correct channel order after the k8in32 swap ([A,R,G,B]→RGBA) — the Acheron backdrop is now correctly green, not red. - XPR files are texture PACKS; add XPR_RES_INDEX to select a resource. - CLI `texture export` now writes real PNGs (texpresso BCn decode + image), replacing the stub. Adds texpresso + image deps. - tests/texture_disc.rs: integration test running the pipeline over real disc .xpr files (28/28 parse). RE debug knobs: XPR_NO_DETILE / XPR_NO_ENDIAN / XPR_FORCE_ENDIAN. KNOWN ISSUE: mipmapped DXT1 textures still decode to noise — mip 0 is not at base_address in the multi-resource packs (localized to a mip storage-offset quirk; uncompressed + de-tile + endian are all verified). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -164,6 +164,11 @@ 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 ───────────────────────────────────────────────────────────
|
||||
@@ -179,6 +184,9 @@ pub struct X360Texture {
|
||||
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.
|
||||
@@ -206,10 +214,20 @@ impl X360Texture {
|
||||
entries.push(Xpr2ResourceEntry::read(&mut cur)?);
|
||||
}
|
||||
|
||||
// Find the first TX2D texture resource
|
||||
// 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()
|
||||
.find(|e| e.is_texture())
|
||||
.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;
|
||||
@@ -239,6 +257,11 @@ impl X360Texture {
|
||||
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;
|
||||
|
||||
@@ -262,7 +285,10 @@ impl X360Texture {
|
||||
}
|
||||
let raw_data = &bytes[data_start..];
|
||||
|
||||
let linear_data = if is_tiled {
|
||||
// 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
|
||||
@@ -276,7 +302,18 @@ impl X360Texture {
|
||||
raw_data[..needed].to_vec()
|
||||
};
|
||||
|
||||
Ok(X360Texture { width, height, format, mip_levels: mip_count, data: linear_data })
|
||||
// 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);
|
||||
}
|
||||
|
||||
Ok(X360Texture { width, height, format, mip_levels: mip_count, is_cubemap, data: linear_data })
|
||||
}
|
||||
|
||||
/// Parse a texture from already-known parameters + raw tiled data.
|
||||
@@ -290,19 +327,82 @@ 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, 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
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core de-tiling algorithm ──────────────────────────────────────────────────
|
||||
|
||||
/// De-tile an Xbox 360 GPU texture from tiled to linear (row-major) layout.
|
||||
/// 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.
|
||||
///
|
||||
/// 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.
|
||||
/// 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.
|
||||
///
|
||||
/// Algorithm based on Xenia's `texture_util.cc` `TileTexture()`.
|
||||
/// `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,
|
||||
@@ -311,50 +411,30 @@ pub fn detile(
|
||||
) -> 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();
|
||||
|
||||
// Texture dimensions in blocks
|
||||
let blocks_wide = ((width + block_size - 1) / block_size).max(1);
|
||||
// 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);
|
||||
|
||||
let expected = blocks_wide as usize * blocks_tall as usize * bpb;
|
||||
if src.len() < expected {
|
||||
return Err(TextureError::BufferTooSmall { needed: expected, have: src.len() });
|
||||
// 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 mut dst = vec![0u8; expected];
|
||||
let dst_len = blocks_wide as usize * blocks_tall as usize * bpb;
|
||||
let mut dst = vec![0u8; dst_len];
|
||||
|
||||
// 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]);
|
||||
}
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,11 +479,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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];
|
||||
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);
|
||||
assert_eq!(result, &src[..8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user