Files
Syplheed-Reborn/crates/sylpheed-formats/tests/texture_disc.rs
MechaCat02 840db9c549 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>
2026-07-10 21:37:20 +02:00

141 lines
5.1 KiB
Rust

//! 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}");
}