fix(texture): crack X360 DXT1 dword-swap; wire correct decode into viewer

DXT1 (108/137 textures in Base.xpr — the bulk of picture assets) decoded to
noise. Root cause found by RE: Xbox 360 BCn blocks store the two 32-bit words
of each 64-bit sub-block in the opposite order to the PC/DDS layout — colour
endpoints live in the HIGH dword, indices in the low one. The endian field
(k8in16) only fixes byte order within the 16-bit words; it does not reorder the
dwords, so without this every DXT texture transposed endpoints/indices → noise.

Isolation that led here:
 - de-tile proven correct for bpb=8 (coherent per-block signature map; the
   linear read is scrambled) — same faithful Xenos Tiled2D as the verified
   bpb=4 ARGB path (green Acheron backdrop).
 - inspecting a smooth region, coherent colour endpoints appeared only in
   bytes[4..8], with the high-entropy indices in bytes[0..4].

Fix: swap_bc_block_dwords() swaps the two dwords within each 64-bit unit after
the byte-level endian swap, for every BCn format. Verified in the real Rust CLI:
weapon skins (rou_f001_wep_*) now decode to clean, recognisable images.

Viewer: DXT1 is fixed transparently (from_xpr2 feeds tex.data straight to the
GPU as Bc1). Also corrected the uncompressed path — post-swap k_8_8_8_8 is
[A,R,G,B]; reorder to [R,G,B,A] and upload as Rgba8UnormSrgb (was Bgra8, wrong).

Knob: XPR_NO_BC_DWORD_SWAP disables the swap for A/B validation.

KNOWN REMAINING: 16-byte blocks (BC2/DXT3, BC3/DXT5) and BC4/BC5 (DXT5A/DXN)
still need their alpha+colour half layout worked out — they decode to noise for
now. DXT1 + uncompressed + cubemaps are correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-10 22:08:23 +02:00
parent 840db9c549
commit 405233e84f
2 changed files with 75 additions and 3 deletions

View File

@@ -313,6 +313,20 @@ impl X360Texture {
apply_endian_swap(&mut linear_data, endianness);
}
// X360 stores BCn (DXT) blocks with the two 32-bit words of each 64-bit
// sub-block in swapped order relative to the PC/DDS layout: the color
// endpoints live in the *high* dword, the indices in the low one. The
// endian field (k8in16) only fixes byte order *within* the 16-bit words;
// it does not reorder the dwords. Without this swap the endpoints and
// indices are transposed and every DXT texture decodes to noise. This is
// an intrinsic property of the format's guest storage, so it is applied
// for every BCn texture regardless of the endian field.
// (Discovered by RE: de-tiled DXT1 blocks had coherent endpoints only in
// bytes[4..8], with the high-entropy indices in bytes[0..4].)
if format.is_block_compressed() && std::env::var("XPR_NO_BC_DWORD_SWAP").is_err() {
swap_bc_block_dwords(&mut linear_data);
}
Ok(X360Texture { width, height, format, mip_levels: mip_count, is_cubemap, data: linear_data })
}
@@ -361,6 +375,22 @@ pub fn apply_endian_swap(data: &mut [u8], endianness: u8) {
}
}
/// Swap the two 32-bit dwords within each 64-bit sub-block, in place.
///
/// Xbox 360 BCn (DXT) textures store each 64-bit block-half with its two 32-bit
/// words in the opposite order to the PC/DDS layout. For BC1/DXT5A (8-byte
/// blocks) this puts the colour endpoints ahead of the indices; for BC2/BC3/DXN
/// (16-byte blocks) it fixes both the alpha half and the colour half. Apply
/// 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"
@@ -496,6 +526,28 @@ mod tests {
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 x360_format_bytes_per_block() {
assert_eq!(X360TextureFormat::Dxt1.bytes_per_block(), 8);