From 405233e84fe93c3c59c99b7f76580ee8b87a7d58 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 10 Jul 2026 22:08:23 +0200 Subject: [PATCH] fix(texture): crack X360 DXT1 dword-swap; wire correct decode into viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/sylpheed-formats/src/texture.rs | 52 ++++++++++++++++++++++ crates/sylpheed-viewer/src/asset_loader.rs | 26 +++++++++-- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/crates/sylpheed-formats/src/texture.rs b/crates/sylpheed-formats/src/texture.rs index 93daad5..5c4c8e7 100644 --- a/crates/sylpheed-formats/src/texture.rs +++ b/crates/sylpheed-formats/src/texture.rs @@ -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 = (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::>()); + } + #[test] fn x360_format_bytes_per_block() { assert_eq!(X360TextureFormat::Dxt1.bytes_per_block(), 8); diff --git a/crates/sylpheed-viewer/src/asset_loader.rs b/crates/sylpheed-viewer/src/asset_loader.rs index e13f91f..fe7ce07 100644 --- a/crates/sylpheed-viewer/src/asset_loader.rs +++ b/crates/sylpheed-viewer/src/asset_loader.rs @@ -93,8 +93,28 @@ impl AssetLoader for Xpr2TextureLoader { pub fn x360_texture_to_bevy_image(tex: X360Texture) -> Result { let format = x360_format_to_wgpu(&tex.format)?; + // Uncompressed k_8_8_8_8: after `from_xpr2`'s k8in32 endian swap the bytes + // are in [A,R,G,B] order (verified against the retail Acheron backdrop). + // wgpu has no ARGB format, so reorder to [R,G,B,A] and upload as Rgba8 + // (see `x360_format_to_wgpu`). BCn data is already GPU-ready. + let data = match tex.format { + X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => { + let opaque = matches!(tex.format, X360TextureFormat::X8R8G8B8); + let mut out = tex.data; + for px in out.chunks_exact_mut(4) { + let (a, r, g, b) = (px[0], px[1], px[2], px[3]); + px[0] = r; + px[1] = g; + px[2] = b; + px[3] = if opaque { 0xFF } else { a }; + } + out + } + _ => tex.data, + }; + Ok(Image { - data: tex.data, + data, texture_descriptor: TextureDescriptor { label: None, size: Extent3d { @@ -132,9 +152,9 @@ fn x360_format_to_wgpu(format: &X360TextureFormat) -> Result TextureFormat::Bc5RgUnorm, // DXT5A / BC4 — single-channel (gloss, specular, luminance maps) X360TextureFormat::Dxt5A => TextureFormat::Bc4RUnorm, - // Uncompressed ARGB — Xbox 360 stores as BGRA in memory + // Uncompressed — reordered to [R,G,B,A] by `x360_texture_to_bevy_image`. X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => { - TextureFormat::Bgra8UnormSrgb + TextureFormat::Rgba8UnormSrgb } }) }