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:
MechaCat02
2026-07-10 21:37:20 +02:00
parent d92c7ddaea
commit 840db9c549
5 changed files with 350 additions and 82 deletions

View File

@@ -19,3 +19,6 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true }
colored = "2"
indicatif = "0.17"
# Texture PNG export: BCn software decode + image encode.
texpresso = "2"
image = { version = "0.25", default-features = false, features = ["png"] }

View File

@@ -330,44 +330,68 @@ fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
let bytes = std::fs::read(file)
.with_context(|| format!("Cannot read {}", file.display()))?;
use sylpheed_formats::texture::{X360Texture, X360TextureFormat};
use sylpheed_formats::texture::X360Texture;
let tex = X360Texture::from_xpr2(&bytes)?;
let rgba = decode_to_rgba8(&tex)
.with_context(|| format!("decoding {:?} texture", tex.format))?;
// For BCn compressed textures we need to software-decompress to RGBA8
// before saving to PNG. This uses the `texpresso` crate.
//
// TODO: add `texpresso = "2"` to Cargo.toml for BCn software decode.
// For now, print a helpful message.
match tex.format {
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
// Raw BGRA8 — can save directly (with channel swizzle)
println!(
"{} TODO: save raw BGRA8 to PNG (add `image` crate)",
"Note:".yellow()
);
}
compressed_format => {
println!(
"{} Format {:?} needs BCn decompression before PNG export.",
"Note:".yellow(), compressed_format
);
println!(
" Add `texpresso` crate and implement decompression in texture.rs"
);
println!(
" Alternatively, use the Bevy viewer to inspect textures visually."
);
}
}
image::save_buffer(
output,
&rgba,
tex.width,
tex.height,
image::ExtendedColorType::Rgba8,
)
.with_context(|| format!("writing PNG {}", output.display()))?;
println!(
" Texture parsed OK: {}×{} {:?}",
tex.width, tex.height, tex.format
"{} {}×{} {:?}{}{}",
"Exported".green().bold(),
tex.width,
tex.height,
tex.format,
if tex.is_cubemap { " (cubemap face 0)" } else { "" },
output.display().to_string().cyan(),
);
Ok(())
}
/// Software-decode a de-tiled `X360Texture` (mip 0) to tightly-packed RGBA8.
///
/// BCn blocks are decompressed with `texpresso`; uncompressed A8R8G8B8/X8R8G8B8
/// is byte-swizzled from the Xenos in-memory BGRA order.
fn decode_to_rgba8(tex: &sylpheed_formats::texture::X360Texture) -> Result<Vec<u8>> {
use sylpheed_formats::texture::X360TextureFormat as F;
let (w, h) = (tex.width as usize, tex.height as usize);
let mut rgba = vec![0u8; w * h * 4];
let bc = |fmt: texpresso::Format, rgba: &mut [u8]| {
fmt.decompress(&tex.data, w, h, rgba);
};
match tex.format {
F::Dxt1 => bc(texpresso::Format::Bc1, &mut rgba),
F::Dxt3 => bc(texpresso::Format::Bc2, &mut rgba),
F::Dxt5 => bc(texpresso::Format::Bc3, &mut rgba),
F::A8R8G8B8 | F::X8R8G8B8 => {
// After the k8in32 endian swap in from_xpr2, k_8_8_8_8 pixels are in
// [A,R,G,B] byte order (verified against the retail Acheron backdrop).
// Emit RGBA. X8 has no meaningful alpha.
let opaque = matches!(tex.format, F::X8R8G8B8);
for (px, out) in tex.data.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
out[0] = px[1]; // R
out[1] = px[2]; // G
out[2] = px[3]; // B
out[3] = if opaque { 0xFF } else { px[0] };
}
}
other => {
anyhow::bail!("PNG export for {other:?} (BC4/BC5) not implemented yet");
}
}
Ok(rgba)
}
// ── pak list ────────────────────────────────────────────────────────────────
/// Best-effort human label for a decompressed entry's inner format.

View File

@@ -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]

View File

@@ -0,0 +1,140 @@
//! Integration test: run the XPR2 texture pipeline against REAL `.xpr` files
//! read directly from the retail disc image, reproducing exactly what the
//! viewer's texture-preview path does (`identify_format` → `X360Texture::
//! from_xpr2`). Skipped unless `SYLPHEED_ISO` points at the disc (or the
//! default dev path exists).
//!
//! Run: `cargo test -p sylpheed-formats --test texture_disc -- --ignored --nocapture`
use std::path::PathBuf;
use sylpheed_formats::texture::X360Texture;
use sylpheed_formats::vfs::identify_format;
fn iso_path() -> Option<PathBuf> {
if let Ok(p) = std::env::var("SYLPHEED_ISO") {
let p = PathBuf::from(p);
if p.is_file() {
return Some(p);
}
}
let default = PathBuf::from(
"/home/fabi/RE Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso",
);
default.is_file().then_some(default)
}
#[tokio::test]
#[ignore = "requires the retail ISO — set SYLPHEED_ISO"]
async fn xpr_pipeline_over_disc_sample() {
let Some(iso) = iso_path() else {
eprintln!("SKIP: ISO not found (set SYLPHEED_ISO)");
return;
};
let mut reader = sylpheed_formats::xiso::open_iso(&iso).await.unwrap();
let all = reader.list_all_files().await.unwrap();
let mut xprs: Vec<String> = all
.into_iter()
.filter(|f| f.to_lowercase().ends_with(".xpr"))
.collect();
xprs.sort();
println!("found {} .xpr files", xprs.len());
// Sample across the set so we hit different texture sizes/formats.
let sample: Vec<String> = xprs.iter().step_by((xprs.len() / 24).max(1)).cloned().collect();
let mut ok = 0usize;
let mut fail = 0usize;
let mut by_format: std::collections::BTreeMap<String, usize> = Default::default();
let mut nonxpr = 0usize;
// Optionally dump the sampled .xpr bytes so the CLI can export them to PNG
// for visual de-tiling validation: `DUMP_XPR_DIR=/tmp/xpr cargo test …`.
let dump_dir = std::env::var("DUMP_XPR_DIR").ok();
if let Some(d) = &dump_dir {
std::fs::create_dir_all(d).unwrap();
}
for path in &sample {
let bytes = reader.read_file(path).await.unwrap();
if let Some(d) = &dump_dir {
let base = path.rsplit('/').next().unwrap();
std::fs::write(format!("{d}/{base}"), &bytes).unwrap();
}
let fmt = identify_format(&bytes);
let magic: String = bytes
.iter()
.take(4)
.map(|b| if b.is_ascii_graphic() { *b as char } else { '.' })
.collect();
if fmt != sylpheed_formats::vfs::FileFormat::Xpr2Texture {
nonxpr += 1;
println!(" [{path}] NOT XPR2 (magic {magic:?}, {} bytes)", bytes.len());
continue;
}
match X360Texture::from_xpr2(&bytes) {
Ok(t) => {
ok += 1;
*by_format.entry(format!("{:?}", t.format)).or_default() += 1;
// Sanity: does the decoded data length match the descriptor
// dimensions (what the GPU upload will require)?
let bs = t.format.block_size() as u32;
let bw = ((t.width + bs - 1) / bs).max(1) as usize;
let bh = ((t.height + bs - 1) / bs).max(1) as usize;
let need = bw * bh * t.format.bytes_per_block();
let size_ok = if need == t.data.len() { "ok" } else { "MISMATCH" };
println!(
" [{path}] {:?} {}x{} mips={} data={} need={} {size_ok}",
t.format, t.width, t.height, t.mip_levels, t.data.len(), need
);
}
Err(e) => {
fail += 1;
println!(" [{path}] from_xpr2 FAILED: {e}");
dump_xpr2_structure(&bytes);
}
}
}
println!("\nSUMMARY: ok={ok} fail={fail} non-xpr2={nonxpr} formats={by_format:?}");
}
/// Dump the XPR2 header + resource directory the way `from_xpr2` reads it,
/// so we can see why a file's TX2D scan comes up empty.
fn dump_xpr2_structure(bytes: &[u8]) {
let be = |o: usize| u32::from_be_bytes(bytes[o..o + 4].try_into().unwrap());
let magic: String = bytes[..4].iter().map(|b| *b as char).collect();
println!(
" hdr magic={magic:?} header_size=0x{:X} data_size=0x{:X} num_resources={}",
be(0x04),
be(0x08),
be(0x0C)
);
let n = be(0x0C).min(16); // guard against garbage counts
for i in 0..n {
let base = 0x10 + i as usize * 16;
if base + 16 > bytes.len() {
break;
}
let tag: String = bytes[base..base + 4]
.iter()
.map(|b| if b.is_ascii_graphic() { *b as char } else { '.' })
.collect();
println!(
" res[{i}] tag={tag:?} data_off=0x{:X} desc_size=0x{:X} name_off=0x{:X}",
be(base + 4),
be(base + 8),
be(base + 12)
);
}
// First 48 bytes hex for orientation.
let hx: String = bytes
.iter()
.take(48)
.map(|b| format!("{b:02X} "))
.collect();
println!(" hex[0..48] {hx}");
}