From 840db9c549d2d308bb18c5d449e089aa2620cf80 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 10 Jul 2026 21:37:20 +0200 Subject: [PATCH] fix(texture): correct Xenos de-tile + endian + cubemaps + PNG export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 11 + crates/sylpheed-cli/Cargo.toml | 3 + crates/sylpheed-cli/src/main.rs | 84 +++++--- crates/sylpheed-formats/src/texture.rs | 194 +++++++++++++----- crates/sylpheed-formats/tests/texture_disc.rs | 140 +++++++++++++ 5 files changed, 350 insertions(+), 82 deletions(-) create mode 100644 crates/sylpheed-formats/tests/texture_disc.rs diff --git a/Cargo.lock b/Cargo.lock index cebff23..f3d86fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4345,8 +4345,10 @@ dependencies = [ "anyhow", "clap", "colored", + "image", "indicatif", "sylpheed-formats", + "texpresso", "tokio", "tracing", "tracing-subscriber", @@ -4437,6 +4439,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "texpresso" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8550677e2259d675a7841cb1403db35f330cc9e58674c8c5caa12dd12c51dc71" +dependencies = [ + "libm", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/crates/sylpheed-cli/Cargo.toml b/crates/sylpheed-cli/Cargo.toml index 4d2c821..ed107fe 100644 --- a/crates/sylpheed-cli/Cargo.toml +++ b/crates/sylpheed-cli/Cargo.toml @@ -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"] } diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index e519f00..012a733 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -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> { + 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. diff --git a/crates/sylpheed-formats/src/texture.rs b/crates/sylpheed-formats/src/texture.rs index f4561cc..93daad5 100644 --- a/crates/sylpheed-formats/src/texture.rs +++ b/crates/sylpheed-formats/src/texture.rs @@ -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::().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::().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 { 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, 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] diff --git a/crates/sylpheed-formats/tests/texture_disc.rs b/crates/sylpheed-formats/tests/texture_disc.rs new file mode 100644 index 0000000..d6b5a1a --- /dev/null +++ b/crates/sylpheed-formats/tests/texture_disc.rs @@ -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 { + 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 = 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 = 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 = 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}"); +}