fix(texture): make BC dword-swap format-aware (don't mangle DXT5A)

The dword-swap that fixes DXT1 is a property of the BC1 COLOUR block, not of
BCn in general. RE on a DXT5A (BC4) mask showed the alpha block is byte-indexed
and wants the endian swap ALONE — dword-swapping it makes it worse. The blanket
swap from the previous commit was therefore breaking the (common) DXT5A/DXN
alpha & normal masks.

Now targeted by format:
 - DXT1            : swap dwords of each 8-byte colour block (verified).
 - DXT2_3 / DXT4_5 : swap only the colour half (bytes 8..16) of each 16-byte
                     block; alpha half endian-only. TENTATIVE — these formats
                     are near-absent in the game assets, so unvalidated against
                     a clear image.
 - DXT5A / DXN     : no dword swap (endian only).

DXT1 export re-verified clean (weapon skins); 16 formats tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-10 22:13:37 +02:00
parent 405233e84f
commit 854fd8dfd3

View File

@@ -313,18 +313,33 @@ 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);
// X360 stores the BC1 *colour* block with its two 32-bit words swapped
// relative to the PC/DDS layout the colour 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, so without this the endpoints/indices are transposed and the
// texture decodes to noise. (Discovered by RE: de-tiled DXT1 blocks had
// coherent endpoints only in bytes[4..8].)
//
// This applies to the colour block only. The BC4-style alpha block
// (DXT5A, and the alpha half of DXT2_3/DXT4_5) is byte-indexed and needs
// the endian swap alone — RE-confirmed: dword-swapping DXT5A makes it
// *worse*. So the swap is format-targeted, not blanket.
if std::env::var("XPR_NO_BC_DWORD_SWAP").is_err() {
match format {
// Pure colour block, 8 bytes.
X360TextureFormat::Dxt1 => swap_bc_block_dwords(&mut linear_data),
// 16-byte block = [alpha 8B][colour 8B]; swap only the colour
// half. TENTATIVE: DXT2_3/DXT4_5 are near-absent in the game's
// assets, so this half-swap is unvalidated against a clear image.
X360TextureFormat::Dxt3 | X360TextureFormat::Dxt5 => {
for block in linear_data.chunks_exact_mut(16) {
swap_bc_block_dwords(&mut block[8..16]);
}
}
// BC4 / BC5 (DXT5A / DXN): alpha/normal blocks, endian swap only.
_ => {}
}
}
Ok(X360Texture { width, height, format, mip_levels: mip_count, is_cubemap, data: linear_data })
@@ -375,12 +390,11 @@ pub fn apply_endian_swap(data: &mut [u8], endianness: u8) {
}
}
/// Swap the two 32-bit dwords within each 64-bit sub-block, in place.
/// Swap the two 32-bit dwords within each 64-bit unit of `data`, 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
/// 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]) {