[texture] Present Canary GPUTEXTUREFORMAT names + metadata; handle XPR5

- 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 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-13 21:09:55 +02:00
parent 390c67cb61
commit 3e1040b472
3 changed files with 199 additions and 3 deletions

View File

@@ -359,9 +359,16 @@ fn cmd_texture_info(file: &Path) -> Result<()> {
let tex = X360Texture::from_xpr2(&bytes) let tex = X360Texture::from_xpr2(&bytes)
.with_context(|| format!("Failed to parse texture: {}", file.display()))?; .with_context(|| format!("Failed to parse texture: {}", file.display()))?;
let d = tex.format.desc();
println!("{} {}", "Texture:".green().bold(), file.display()); println!("{} {}", "Texture:".green().bold(), file.display());
println!(" Resolution : {}×{}", tex.width.to_string().yellow(), tex.height.to_string().yellow()); 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!(" Mip levels : {}", tex.mip_levels);
println!(" Data size : {} bytes", tex.data.len().to_string().yellow()); println!(" Data size : {} bytes", tex.data.len().to_string().yellow());

View File

@@ -52,7 +52,10 @@ pub enum TextureError {
#[error("No TX2D texture resource found in XPR2 file")] #[error("No TX2D texture resource found in XPR2 file")]
NoTextureFound, 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), UnsupportedFormat(u8),
#[error("Buffer too small: need {needed} bytes, have {have}")] #[error("Buffer too small: need {needed} bytes, have {have}")]
@@ -125,6 +128,159 @@ impl X360TextureFormat {
pub fn block_size(&self) -> usize { pub fn block_size(&self) -> usize {
if self.is_block_compressed() { 4 } else { 1 } 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<String> {
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 ───────────────────────────────────────────────────── // ── XPR2 container format ─────────────────────────────────────────────────────
@@ -261,6 +417,7 @@ impl X360Texture {
/// Decode the `want`-th texture resource (`TX2D` / `TXCM`, directory order). /// Decode the `want`-th texture resource (`TX2D` / `TXCM`, directory order).
pub fn from_xpr2_index(bytes: &[u8], want: usize) -> Result<Self, TextureError> { pub fn from_xpr2_index(bytes: &[u8], want: usize) -> Result<Self, TextureError> {
use std::io::Cursor; use std::io::Cursor;
ensure_xpr2(bytes)?;
let mut cur = Cursor::new(bytes); let mut cur = Cursor::new(bytes);
// Parse header — validates "XPR2" magic, reads 3 × u32 (total 16 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.) /// `BG_Acheron`: `data_size == 6 × 0x400000` and all 6 faces decode cleanly.)
pub fn cube_faces_from_xpr2(bytes: &[u8]) -> Result<Option<Cubemap>, TextureError> { pub fn cube_faces_from_xpr2(bytes: &[u8]) -> Result<Option<Cubemap>, TextureError> {
use std::io::Cursor; use std::io::Cursor;
ensure_xpr2(bytes)?;
let mut cur = Cursor::new(bytes); let mut cur = Cursor::new(bytes);
let header = Xpr2Header::read(&mut cur)?; let header = Xpr2Header::read(&mut cur)?;
let mut entries = Vec::new(); let mut entries = Vec::new();
@@ -640,6 +798,25 @@ fn compact_bits(mut x: u32) -> u32 {
mod tests { mod tests {
use super::*; 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] #[test]
fn morton_decode_corners() { fn morton_decode_corners() {
assert_eq!(morton_decode(0), (0, 0)); assert_eq!(morton_decode(0), (0, 0));

View File

@@ -1993,7 +1993,19 @@ fn apply_loaded_texture(
let w = tex.width; let w = tex.width;
let h = tex.height; let h = tex.height;
let mips = tex.mip_levels; 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) { let image = match crate::asset_loader::x360_texture_to_bevy_image(tex) {
Ok(img) => img, Ok(img) => img,