From 3e1040b472468352c9d8c4957e86bd1160c1b299 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 13 Jul 2026 21:09:55 +0200 Subject: [PATCH] [texture] Present Canary GPUTEXTUREFORMAT names + metadata; handle XPR5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Port xenia-canary's complete 64-entry texture-format table (xenos.h enum + texture_info_formats.inl) into GPU_FORMATS + gpu_format_name()/desc() and X360TextureFormat::gpu_name()/desc(). - Viewer + CLI `texture info` now show the canonical k_… name, bpp and compressed flag (e.g. "k_DXT1 (Dxt1) · 4 bpp, compressed"); UnsupportedFormat now names the format instead of a bare hex code. - Detect non-XPR2 containers up front: the game ships one XPR5 file (Common.xpr) alongside 165 XPR2 — report UnsupportedContainer("XPR5") cleanly instead of a "bad magic" parse error. - Add a table-integrity test (index==code, spot-checks, decodable-variant consistency). Static-RE scan of the 166 resource3d XPRs: only k_DXT1 (145), k_8_8_8_8 (19), k_DXT5A (1) are used — all already decoded, so this is presentation, not new decoders. Co-Authored-By: Claude Opus 4.8 --- crates/sylpheed-cli/src/main.rs | 9 +- crates/sylpheed-formats/src/texture.rs | 179 ++++++++++++++++++++++- crates/sylpheed-viewer/src/iso_loader.rs | 14 +- 3 files changed, 199 insertions(+), 3 deletions(-) diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index b20c1a8..d75cdaf 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -359,9 +359,16 @@ fn cmd_texture_info(file: &Path) -> Result<()> { let tex = X360Texture::from_xpr2(&bytes) .with_context(|| format!("Failed to parse texture: {}", file.display()))?; + let d = tex.format.desc(); println!("{} {}", "Texture:".green().bold(), file.display()); println!(" Resolution : {}×{}", tex.width.to_string().yellow(), tex.height.to_string().yellow()); - println!(" Format : {:?}", tex.format); + println!( + " Format : {} ({:?}) · {} bpp, {}", + tex.format.gpu_name().yellow(), + tex.format, + d.bpp, + if d.compressed { "compressed" } else { "uncompressed" }, + ); println!(" Mip levels : {}", tex.mip_levels); println!(" Data size : {} bytes", tex.data.len().to_string().yellow()); diff --git a/crates/sylpheed-formats/src/texture.rs b/crates/sylpheed-formats/src/texture.rs index f2afd49..4d791bb 100644 --- a/crates/sylpheed-formats/src/texture.rs +++ b/crates/sylpheed-formats/src/texture.rs @@ -52,7 +52,10 @@ pub enum TextureError { #[error("No TX2D texture resource found in XPR2 file")] NoTextureFound, - #[error("Unsupported texture format: 0x{0:02X}")] + #[error("Unsupported XPR container '{0}' (this reader handles XPR2 only)")] + UnsupportedContainer(String), + + #[error("Unsupported texture format: {} (0x{:02X})", gpu_format_name(*.0), .0)] UnsupportedFormat(u8), #[error("Buffer too small: need {needed} bytes, have {have}")] @@ -125,6 +128,159 @@ impl X360TextureFormat { pub fn block_size(&self) -> usize { if self.is_block_compressed() { 4 } else { 1 } } + + /// Canary's canonical `k_…` GPUTEXTUREFORMAT name (e.g. `k_DXT1`). + pub fn gpu_name(&self) -> &'static str { + gpu_format_name(*self as u8) + } + + /// The full Canary format descriptor (bpp, block dims, compression). + pub fn desc(&self) -> &'static GpuFormatDesc { + // Every enum value has a valid entry in the 0..=63 table. + &GPU_FORMATS[*self as usize] + } +} + +// ── GPUTEXTUREFORMAT reference table (ported from xenia-canary) ───────────────── + +/// One row of Xenia's GPU texture-format table. `bpp` is bits per pixel; for a +/// block-compressed format one "block" covers `block_w × block_h` texels. +/// Source: xenia-canary `src/xenia/gpu/xenos.h` (`enum class TextureFormat`) + +/// `src/xenia/gpu/texture_info_formats.inl` (`FORMAT_INFO(...)`). +#[derive(Debug, Clone, Copy)] +pub struct GpuFormatDesc { + /// 6-bit GPUTEXTUREFORMAT code (GPUFC dword_1 bits[5:0]). + pub code: u8, + /// Canary's canonical name, e.g. `k_8_8_8_8`, `k_DXT4_5`. + pub name: &'static str, + pub block_w: u8, + pub block_h: u8, + pub bpp: u16, + pub compressed: bool, +} + +impl GpuFormatDesc { + /// Bytes per block (or per pixel when `block_w == block_h == 1`). + pub fn bytes_per_block(&self) -> usize { + self.block_w as usize * self.block_h as usize * self.bpp as usize / 8 + } +} + +const fn d( + code: u8, + name: &'static str, + block_w: u8, + block_h: u8, + bpp: u16, + compressed: bool, +) -> GpuFormatDesc { + GpuFormatDesc { code, name, block_w, block_h, bpp, compressed } +} + +/// The complete GPUTEXTUREFORMAT table (codes 0..=63), verbatim from +/// xenia-canary. Lets us name/describe *any* texture format the game uses — +/// even ones this crate can't yet decode — instead of a bare hex code. +#[rustfmt::skip] +pub const GPU_FORMATS: [GpuFormatDesc; 64] = [ + d(0, "k_1_REVERSE", 1, 1, 1, false), + d(1, "k_1", 1, 1, 1, false), + d(2, "k_8", 1, 1, 8, false), + d(3, "k_1_5_5_5", 1, 1, 16, false), + d(4, "k_5_6_5", 1, 1, 16, false), + d(5, "k_6_5_5", 1, 1, 16, false), + d(6, "k_8_8_8_8", 1, 1, 32, false), + d(7, "k_2_10_10_10", 1, 1, 32, false), + d(8, "k_8_A", 1, 1, 8, false), + d(9, "k_8_B", 1, 1, 8, false), + d(10, "k_8_8", 1, 1, 16, false), + d(11, "k_Cr_Y1_Cb_Y0_REP", 2, 1, 16, true), + d(12, "k_Y1_Cr_Y0_Cb_REP", 2, 1, 16, true), + d(13, "k_16_16_EDRAM", 1, 1, 32, false), + d(14, "k_8_8_8_8_A", 1, 1, 32, false), + d(15, "k_4_4_4_4", 1, 1, 16, false), + d(16, "k_10_11_11", 1, 1, 32, false), + d(17, "k_11_11_10", 1, 1, 32, false), + d(18, "k_DXT1", 4, 4, 4, true), + d(19, "k_DXT2_3", 4, 4, 8, true), + d(20, "k_DXT4_5", 4, 4, 8, true), + d(21, "k_16_16_16_16_EDRAM", 1, 1, 64, false), + d(22, "k_24_8", 1, 1, 32, false), + d(23, "k_24_8_FLOAT", 1, 1, 32, false), + d(24, "k_16", 1, 1, 16, false), + d(25, "k_16_16", 1, 1, 32, false), + d(26, "k_16_16_16_16", 1, 1, 64, false), + d(27, "k_16_EXPAND", 1, 1, 16, false), + d(28, "k_16_16_EXPAND", 1, 1, 32, false), + d(29, "k_16_16_16_16_EXPAND", 1, 1, 64, false), + d(30, "k_16_FLOAT", 1, 1, 16, false), + d(31, "k_16_16_FLOAT", 1, 1, 32, false), + d(32, "k_16_16_16_16_FLOAT", 1, 1, 64, false), + d(33, "k_32", 1, 1, 32, false), + d(34, "k_32_32", 1, 1, 64, false), + d(35, "k_32_32_32_32", 1, 1, 128, false), + d(36, "k_32_FLOAT", 1, 1, 32, false), + d(37, "k_32_32_FLOAT", 1, 1, 64, false), + d(38, "k_32_32_32_32_FLOAT", 1, 1, 128, false), + d(39, "k_32_AS_8", 4, 1, 8, true), + d(40, "k_32_AS_8_8", 2, 1, 16, true), + d(41, "k_16_MPEG", 1, 1, 16, false), + d(42, "k_16_16_MPEG", 1, 1, 32, false), + d(43, "k_8_INTERLACED", 1, 1, 8, false), + d(44, "k_32_AS_8_INTERLACED", 4, 1, 8, true), + d(45, "k_32_AS_8_8_INTERLACED", 1, 1, 16, true), + d(46, "k_16_INTERLACED", 1, 1, 16, false), + d(47, "k_16_MPEG_INTERLACED", 1, 1, 16, false), + d(48, "k_16_16_MPEG_INTERLACED", 1, 1, 32, false), + d(49, "k_DXN", 4, 4, 8, true), + d(50, "k_8_8_8_8_AS_16_16_16_16", 1, 1, 32, false), + d(51, "k_DXT1_AS_16_16_16_16", 4, 4, 4, true), + d(52, "k_DXT2_3_AS_16_16_16_16", 4, 4, 8, true), + d(53, "k_DXT4_5_AS_16_16_16_16", 4, 4, 8, true), + d(54, "k_2_10_10_10_AS_16_16_16_16", 1, 1, 32, false), + d(55, "k_10_11_11_AS_16_16_16_16", 1, 1, 32, false), + d(56, "k_11_11_10_AS_16_16_16_16", 1, 1, 32, false), + d(57, "k_32_32_32_FLOAT", 1, 1, 96, false), + d(58, "k_DXT3A", 4, 4, 4, true), + d(59, "k_DXT5A", 4, 4, 4, true), + d(60, "k_CTX1", 4, 4, 4, true), + d(61, "k_DXT3A_AS_1_1_1_1", 4, 4, 4, true), + d(62, "k_8_8_8_8_GAMMA_EDRAM", 1, 1, 32, false), + d(63, "k_2_10_10_10_FLOAT_EDRAM", 1, 1, 32, false), +]; + +/// Canary's canonical name for a 6-bit GPUTEXTUREFORMAT code, or +/// `"unknown(NN)"` when out of the 0..=63 range. +pub fn gpu_format_name(code: u8) -> &'static str { + GPU_FORMATS + .get(code as usize) + .map(|f| f.name) + .unwrap_or("unknown") +} + +/// The full descriptor for a GPUTEXTUREFORMAT code, if in range. +pub fn gpu_format_desc(code: u8) -> Option<&'static GpuFormatDesc> { + GPU_FORMATS.get(code as usize) +} + +/// Identify the XPR container variant from the 4-byte magic (`XPR0`/`XPR2`/ +/// `XPR5`/…), so callers can report `XPR5` clearly instead of "bad magic". +pub fn xpr_container_kind(bytes: &[u8]) -> Option { + if bytes.len() >= 4 && &bytes[..3] == b"XPR" { + Some(String::from_utf8_lossy(&bytes[..4]).into_owned()) + } else { + None + } +} + +/// Guard the XPR2-only decode paths: turn a non-XPR2 container into a clear +/// [`TextureError::UnsupportedContainer`] before binrw reports a generic +/// magic mismatch. (The game also ships some `XPR5` packages, e.g. Common.xpr.) +fn ensure_xpr2(bytes: &[u8]) -> Result<(), TextureError> { + match xpr_container_kind(bytes) { + Some(k) if k == "XPR2" => Ok(()), + Some(k) => Err(TextureError::UnsupportedContainer(k)), + None => Ok(()), // let the normal magic check produce BadMagic + } } // ── XPR2 container format ───────────────────────────────────────────────────── @@ -261,6 +417,7 @@ impl X360Texture { /// Decode the `want`-th texture resource (`TX2D` / `TXCM`, directory order). pub fn from_xpr2_index(bytes: &[u8], want: usize) -> Result { use std::io::Cursor; + ensure_xpr2(bytes)?; let mut cur = Cursor::new(bytes); // Parse header — validates "XPR2" magic, reads 3 × u32 (total 16 bytes) @@ -353,6 +510,7 @@ impl X360Texture { /// `BG_Acheron`: `data_size == 6 × 0x400000` and all 6 faces decode cleanly.) pub fn cube_faces_from_xpr2(bytes: &[u8]) -> Result, TextureError> { use std::io::Cursor; + ensure_xpr2(bytes)?; let mut cur = Cursor::new(bytes); let header = Xpr2Header::read(&mut cur)?; let mut entries = Vec::new(); @@ -640,6 +798,25 @@ fn compact_bits(mut x: u32) -> u32 { mod tests { use super::*; + #[test] + fn gpu_format_table_is_index_aligned() { + // The hand-transcribed Canary table must stay index==code for all 64. + for (i, f) in GPU_FORMATS.iter().enumerate() { + assert_eq!(f.code as usize, i, "GPU_FORMATS[{i}] has code {}", f.code); + } + // Spot-check the formats the game actually uses (from xenos.h + .inl). + assert_eq!(gpu_format_name(6), "k_8_8_8_8"); + assert_eq!(gpu_format_name(18), "k_DXT1"); + assert_eq!(gpu_format_name(59), "k_DXT5A"); + assert_eq!(gpu_format_name(200), "unknown"); + // Every decodable enum variant resolves to a compressed/uncompressed + // descriptor consistent with its own is_block_compressed(). + for code in [6u8, 7, 18, 19, 20, 49, 59] { + let fmt = X360TextureFormat::from_u8(code).unwrap(); + assert_eq!(fmt.desc().compressed, fmt.is_block_compressed(), "code {code}"); + } + } + #[test] fn morton_decode_corners() { assert_eq!(morton_decode(0), (0, 0)); diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index ea78b40..da892af 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -1993,7 +1993,19 @@ fn apply_loaded_texture( let w = tex.width; let h = tex.height; let mips = tex.mip_levels; - let fmt_str = format!("{:?} {}×{} {} mip(s)", tex.format, w, h, mips); + // Canary-sourced format name + metadata (GPUTEXTUREFORMAT), e.g. + // "k_DXT1 (Dxt1) · 256×256 · 9 mip(s) · 4 bpp, compressed". + let d = tex.format.desc(); + let fmt_str = format!( + "{} ({:?}) · {}×{} · {} mip(s) · {} bpp, {}", + tex.format.gpu_name(), + tex.format, + w, + h, + mips, + d.bpp, + if d.compressed { "compressed" } else { "uncompressed" }, + ); let image = match crate::asset_loader::x360_texture_to_bevy_image(tex) { Ok(img) => img,