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:
@@ -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"] }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
140
crates/sylpheed-formats/tests/texture_disc.rs
Normal file
140
crates/sylpheed-formats/tests/texture_disc.rs
Normal 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}");
|
||||
}
|
||||
Reference in New Issue
Block a user