diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index 36270bf2..6594924d 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -103,6 +103,21 @@ enum Commands { #[command(subcommand)] cmd: MeshCommands, }, + + /// Audio tools (identify WAV/XMA/XMA2 + metadata) + Audio { + #[command(subcommand)] + cmd: AudioCommands, + }, +} + +#[derive(Subcommand)] +enum AudioCommands { + /// Identify an audio file/stream and print its metadata + Info { + /// Path to a WAV / XMA / raw audio stream + file: PathBuf, + }, } #[derive(Subcommand)] @@ -219,9 +234,41 @@ async fn main() -> Result<()> { cmd_pak_textures(&pak, &output, verbose) } }, + Commands::Audio { cmd } => match cmd { + AudioCommands::Info { file } => cmd_audio_info(&file), + }, } } +// ── audio info ─────────────────────────────────────────────────────────────── + +fn cmd_audio_info(file: &Path) -> Result<()> { + use sylpheed_formats::AudioInfo; + let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?; + let info = AudioInfo::probe(&bytes); + + println!("{} {}", "Audio:".green().bold(), file.display()); + println!(" Codec : {}", info.codec.label().yellow()); + let opt = |v: Option| v.unwrap_or_else(|| "—".dimmed().to_string()); + println!(" Channels : {}", opt(info.channels.map(|c| c.to_string()))); + println!(" Sample rate: {}", opt(info.sample_rate.map(|r| format!("{r} Hz")))); + println!(" Bit depth : {}", opt(info.bits_per_sample.map(|b| format!("{b}-bit")))); + if let Some(d) = info.duration_secs { + println!(" Duration : {d:.2} s"); + } + if let Some(p) = info.xma_packets { + println!(" XMA packets: {} (2048 B each)", p.to_string().yellow()); + } + println!(" Size : {} bytes", info.size_bytes.to_string().yellow()); + if info.codec.needs_decoder() { + println!( + " {} decode not supported (needs an XMA2 decoder + the sound-bank descriptor)", + "note:".dimmed() + ); + } + Ok(()) +} + // ── extract ──────────────────────────────────────────────────────────────── async fn cmd_extract(iso_path: &Path, output_dir: &Path) -> Result<()> { @@ -376,9 +423,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()); @@ -913,24 +967,6 @@ fn decode_to_rgba8(tex: &sylpheed_formats::texture::X360Texture) -> Result Option { - let mut cands: Vec<&str> = Vec::new(); - for key in ["ID", "Name", "Model"] { - if let Some(v) = obj.get_raw(key) { - cands.push(v); - } - } - for t in obj.tokens() { - if t.contains('_') || t.len() >= 5 { - cands.push(t.as_str()); - } - } - sylpheed_formats::hash::recover_toc_name(name_hash, &cands) -} - fn cmd_pak_list(pak: &Path, idxd_only: bool) -> Result<()> { let arc = PakArchive::open(pak).with_context(|| format!("opening {}", pak.display()))?; println!( @@ -958,7 +994,7 @@ fn cmd_pak_list(pak: &Path, idxd_only: bool) -> Result<()> { } let (detail, name) = if is_idxd { match IdxdObject::parse(&payload) { - Ok(o) => (o.identity(), idxd_toc_name(&o, e.name_hash)), + Ok(o) => (o.identity(), o.recover_toc_path(e.name_hash)), Err(_) => (String::new(), None), } } else { diff --git a/crates/sylpheed-formats/src/audio.rs b/crates/sylpheed-formats/src/audio.rs index 9d343fd9..90bd0ee0 100644 --- a/crates/sylpheed-formats/src/audio.rs +++ b/crates/sylpheed-formats/src/audio.rs @@ -1,85 +1,405 @@ -//! Audio format parsing — XMA and XWB handling. +//! Audio format parsing — WAV/PCM decode + Xbox 360 XMA/XMA2 recognition. //! -//! Xbox 360 games use **XMA** (Xbox Media Audio) as their primary audio codec. -//! XMA is a proprietary Microsoft codec derived from WMA Pro. +//! ## What the game actually ships +//! Project Sylpheed's in-game audio lives in `dat/sound.pak` (IPFB): ~9500 +//! entries of **raw, header-less XMA2** stream data — no per-entry `RIFF`/`fmt ` +//! header, no compression wrapper (identical sound effects are byte-for-byte +//! duplicate entries). The per-stream format (channel count, sample rate, loop +//! points) is therefore **not** in the stream data; it lives in a separate +//! sound-bank descriptor that has not been reverse engineered yet. So this +//! module can *identify* those streams (and count their XMA packets) but cannot +//! yet decode them to PCM — that needs (a) the bank descriptor and (b) an XMA2 +//! decoder. //! -//! ## The Challenge -//! XMA decoding requires Microsoft's proprietary XMA decoder, which is only -//! available in the Windows DirectX runtime. There are two approaches: +//! ## What this module does today +//! - Fully parses **RIFF/WAVE PCM** (8/16/24-bit int, 32-bit float) → `GameAudio` +//! (interleaved `f32`), ready for playback/export of any converted audio. +//! - Reads metadata from **RIFF/WAVE XMA (`0x0165`) / XMA2 (`0x0166`)** headers +//! (channels, sample rate) — decode still unsupported. +//! - Recognizes **raw XMA2** stream blobs by entropy + packet alignment and +//! reports the 2048-byte packet count. //! -//! ### Option A: Convert on extraction (recommended for Milestone 1) -//! Use `xma2encode.exe` (from Xbox 360 SDK) or `ffmpeg` (has partial XMA support) -//! to pre-convert all audio to OGG/WAV during the extraction step. -//! -//! ```bash -//! # Convert a single XMA file to WAV using ffmpeg -//! ffmpeg -i audio.xma output.wav -//! -//! # Or use VGMStream's test.exe for more accurate XMA decoding -//! ``` -//! -//! ### Option B: XMA2 software decoder -//! The `xma2dec` project provides an open-source XMA2 decoder. -//! GitHub: https://github.com/koolkdev/xmalib (research-quality) -//! -//! ### XWB Wave Bank format -//! Xbox 360 games typically bundle audio into XWB (Xbox Wave Bank) files. -//! These are containers that hold multiple XMA streams. -//! Reference: https://github.com/microsoft/DirectXTK/tree/main/Audio +//! XMA framing constants are from xenia-canary `src/xenia/apu/xma_context.h` +//! (`kBytesPerPacket = 2048`, `kSamplesPerFrame = 512`, `kBytesPerSample = 2`). use thiserror::Error; +// XMA framing (xenia-canary xma_context.h). +/// One XMA2 packet is 2048 bytes (4-byte header + 2044 bytes of frame data). +pub const XMA_BYTES_PER_PACKET: usize = 2048; +/// Decoded PCM samples produced per XMA frame, per channel. +pub const XMA_SAMPLES_PER_FRAME: u32 = 512; + +// WAVE format tags. +const WAVE_FORMAT_PCM: u16 = 0x0001; +const WAVE_FORMAT_IEEE_FLOAT: u16 = 0x0003; +const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE; +const WAVE_FORMAT_XMA: u16 = 0x0165; +const WAVE_FORMAT_XMA2: u16 = 0x0166; + #[derive(Debug, Error)] pub enum AudioError { - #[error("Unknown audio format magic: {0:?}")] - UnknownMagic([u8; 4]), - #[error("XMA decoding requires pre-conversion. See audio.rs for instructions.")] - XmaNotSupported, - #[error("Parse error: {0}")] - Parse(String), + #[error("not a recognized audio container")] + Unrecognized, + #[error("malformed audio: {0}")] + Malformed(&'static str), + #[error("codec needs a decoder this crate does not provide: {0:?}")] + NeedsDecoder(AudioCodec), + #[error("unsupported PCM sample format: tag {tag:#06x}, {bits} bits")] + UnsupportedPcm { tag: u16, bits: u16 }, } -/// An audio clip decoded to raw PCM, ready for Bevy's audio system. +/// Recognized audio container / codec. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AudioCodec { + /// RIFF/WAVE integer PCM (`WAVE_FORMAT_PCM`). + Pcm, + /// RIFF/WAVE IEEE-float PCM (`WAVE_FORMAT_IEEE_FLOAT`). + PcmFloat, + /// RIFF/WAVE XMA (`0x0165`). + Xma, + /// RIFF/WAVE XMA2 (`0x0166`). + Xma2, + /// Header-less XMA2 stream (the game's `sound.pak` entries), identified + /// heuristically — no embedded channel/rate metadata. + RawXma2, + Unknown, +} + +impl AudioCodec { + /// Does decoding this codec require support this crate does not (yet) have? + pub fn needs_decoder(&self) -> bool { + matches!(self, Self::Xma | Self::Xma2 | Self::RawXma2) + } + + pub fn label(&self) -> &'static str { + match self { + Self::Pcm => "PCM", + Self::PcmFloat => "PCM float", + Self::Xma => "XMA (RIFF)", + Self::Xma2 => "XMA2 (RIFF)", + Self::RawXma2 => "XMA2 (raw stream)", + Self::Unknown => "unknown", + } + } +} + +/// Non-decoding metadata describing an audio blob. +#[derive(Debug, Clone)] +pub struct AudioInfo { + pub codec: AudioCodec, + pub channels: Option, + pub sample_rate: Option, + pub bits_per_sample: Option, + /// PCM samples per channel, when derivable (WAV only). + pub samples_per_channel: Option, + pub duration_secs: Option, + pub size_bytes: usize, + /// 2048-byte XMA packet count, for XMA/raw-XMA streams. + pub xma_packets: Option, +} + +impl AudioInfo { + fn empty(codec: AudioCodec, size: usize) -> Self { + Self { + codec, + channels: None, + sample_rate: None, + bits_per_sample: None, + samples_per_channel: None, + duration_secs: None, + size_bytes: size, + xma_packets: None, + } + } + + /// Best-effort classification of an audio blob. Never fails: unrecognized + /// input yields [`AudioCodec::Unknown`]. + /// + /// Order matters: a `RIFF/WAVE` header is authoritative; only header-less, + /// high-entropy, packet-sized blobs fall through to the raw-XMA2 heuristic. + pub fn probe(bytes: &[u8]) -> Self { + if let Some(info) = parse_riff_wave(bytes) { + return info; + } + if looks_like_raw_xma2(bytes) { + let mut info = Self::empty(AudioCodec::RawXma2, bytes.len()); + info.xma_packets = Some(bytes.len().div_ceil(XMA_BYTES_PER_PACKET) as u32); + return info; + } + Self::empty(AudioCodec::Unknown, bytes.len()) + } +} + +/// True when `bytes` is likely a raw, header-less XMA2 stream: no known audio +/// magic, but a large, near-incompressible (high-entropy) payload — the shape +/// of the game's `sound.pak` audio entries. Heuristic: not conclusive, but the +/// probe runs only after every structured format has been ruled out. +pub fn looks_like_raw_xma2(bytes: &[u8]) -> bool { + // XMA streams are always at least a couple of packets long. + if bytes.len() < XMA_BYTES_PER_PACKET * 2 { + return false; + } + if bytes.starts_with(b"RIFF") || bytes.starts_with(b"XMA2") { + return false; // handled by the RIFF path + } + shannon_entropy_bits(&bytes[..bytes.len().min(64 * 1024)]) >= 7.8 +} + +/// Shannon entropy in bits/byte over `data` (0.0..=8.0). Compressed audio sits +/// very close to 8; structured/text data is much lower. +fn shannon_entropy_bits(data: &[u8]) -> f64 { + if data.is_empty() { + return 0.0; + } + let mut counts = [0u32; 256]; + for &b in data { + counts[b as usize] += 1; + } + let n = data.len() as f64; + counts + .iter() + .filter(|&&c| c > 0) + .map(|&c| { + let p = c as f64 / n; + -p * p.log2() + }) + .sum() +} + +// ── RIFF/WAVE ────────────────────────────────────────────────────────────────── + +/// Parse a RIFF/WAVE header for metadata. Returns `None` if `bytes` is not a +/// `RIFF....WAVE` container. All RIFF fields are little-endian. +fn parse_riff_wave(bytes: &[u8]) -> Option { + if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" { + return None; + } + let le16 = |o: usize| u16::from_le_bytes([bytes[o], bytes[o + 1]]); + let le32 = + |o: usize| u32::from_le_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]); + + let mut pos = 12; + let (mut tag, mut channels, mut rate, mut bits) = (0u16, 0u16, 0u32, 0u16); + let mut data_bytes: Option = None; + let mut have_fmt = false; + + while pos + 8 <= bytes.len() { + let id = &bytes[pos..pos + 4]; + let size = le32(pos + 4) as usize; + let body = pos + 8; + match id { + b"fmt " if body + 16 <= bytes.len() => { + tag = le16(body); + channels = le16(body + 2); + rate = le32(body + 4); + bits = le16(body + 14); + // WAVE_FORMAT_EXTENSIBLE stores the real tag in the GUID's first + // two bytes, right after cbSize (+2) → +24 from the fmt body. + if tag == WAVE_FORMAT_EXTENSIBLE && body + 26 <= bytes.len() { + tag = le16(body + 24); + } + have_fmt = true; + } + b"data" => data_bytes = Some(size as u64), + _ => {} + } + // Chunks are word-aligned (pad byte when size is odd). + pos = body + size + (size & 1); + } + if !have_fmt { + return None; + } + + let codec = match tag { + WAVE_FORMAT_PCM => AudioCodec::Pcm, + WAVE_FORMAT_IEEE_FLOAT => AudioCodec::PcmFloat, + WAVE_FORMAT_XMA => AudioCodec::Xma, + WAVE_FORMAT_XMA2 => AudioCodec::Xma2, + _ => AudioCodec::Unknown, + }; + let mut info = AudioInfo::empty(codec, bytes.len()); + info.channels = Some(channels).filter(|&c| c > 0); + info.sample_rate = Some(rate).filter(|&r| r > 0); + info.bits_per_sample = Some(bits).filter(|&b| b > 0); + + match codec { + AudioCodec::Pcm | AudioCodec::PcmFloat => { + if let (Some(d), true) = (data_bytes, channels > 0 && bits > 0) { + let frame = channels as u64 * (bits as u64 / 8); + if frame > 0 { + let spc = d / frame; + info.samples_per_channel = Some(spc); + if rate > 0 { + info.duration_secs = Some(spc as f32 / rate as f32); + } + } + } + } + AudioCodec::Xma | AudioCodec::Xma2 => { + if let Some(d) = data_bytes { + info.xma_packets = Some((d / XMA_BYTES_PER_PACKET as u64) as u32); + } + } + _ => {} + } + Some(info) +} + +// ── PCM decode → GameAudio ───────────────────────────────────────────────────── + +/// An audio clip decoded to raw interleaved `f32` PCM (`L R L R …`). #[derive(Debug, Clone)] pub struct GameAudio { - /// Raw PCM samples (interleaved for stereo: L R L R ...) pub samples: Vec, pub channels: u16, pub sample_rate: u32, } impl GameAudio { - /// Parse audio from raw bytes. - /// - /// Currently only WAV/PCM is supported. For XMA files, pre-convert using: - /// `ffmpeg -i file.xma file.wav` - pub fn from_bytes(bytes: &[u8]) -> Result { - // Check for WAV magic - if bytes.len() >= 4 && &bytes[..4] == b"RIFF" { - return Self::from_wav(bytes); + /// Decode a RIFF/WAVE **PCM** file (int 8/16/24-bit or 32-bit float) to + /// interleaved `f32`. XMA/XMA2 return [`AudioError::NeedsDecoder`]. + pub fn from_wav(bytes: &[u8]) -> Result { + let info = parse_riff_wave(bytes).ok_or(AudioError::Unrecognized)?; + if info.codec.needs_decoder() { + return Err(AudioError::NeedsDecoder(info.codec)); } + let channels = info.channels.ok_or(AudioError::Malformed("no channels"))?; + let rate = info.sample_rate.ok_or(AudioError::Malformed("no sample rate"))?; + let bits = info.bits_per_sample.ok_or(AudioError::Malformed("no bit depth"))?; - // XMA magic bytes - if bytes.len() >= 4 && ( - &bytes[..4] == b"XWB\0" || // Wave bank - &bytes[..4] == b"XMA2" // Raw XMA2 - ) { - return Err(AudioError::XmaNotSupported); - } + // Locate the `data` chunk body. + let (off, len) = riff_data_span(bytes).ok_or(AudioError::Malformed("no data chunk"))?; + let data = &bytes[off..off + len]; - Err(AudioError::UnknownMagic( - bytes[..4.min(bytes.len())].try_into().unwrap_or([0u8; 4]) - )) - } - - fn from_wav(bytes: &[u8]) -> Result { - // Minimal WAV parser for PCM files - // A proper implementation should use the `hound` crate: - // https://crates.io/crates/hound - // - // TODO: Add `hound = "3"` to Cargo.toml and implement properly - Err(AudioError::Parse( - "WAV parsing TODO: add `hound` crate and implement".to_string() - )) + let samples: Vec = match (info.codec, bits) { + (AudioCodec::Pcm, 8) => data.iter().map(|&b| (b as f32 - 128.0) / 128.0).collect(), + (AudioCodec::Pcm, 16) => data + .chunks_exact(2) + .map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0) + .collect(), + (AudioCodec::Pcm, 24) => data + .chunks_exact(3) + .map(|c| { + let v = ((c[2] as i32) << 16) | ((c[1] as i32) << 8) | c[0] as i32; + let v = (v << 8) >> 8; // sign-extend 24→32 + v as f32 / 8_388_608.0 + }) + .collect(), + (AudioCodec::PcmFloat, 32) => data + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect(), + _ => return Err(AudioError::UnsupportedPcm { tag: 0, bits }), + }; + Ok(Self { samples, channels, sample_rate: rate }) + } +} + +/// Byte span (offset, length) of the WAVE `data` chunk body, if present. +fn riff_data_span(bytes: &[u8]) -> Option<(usize, usize)> { + if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" { + return None; + } + let le32 = + |o: usize| u32::from_le_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]) as usize; + let mut pos = 12; + while pos + 8 <= bytes.len() { + let size = le32(pos + 4); + let body = pos + 8; + if &bytes[pos..pos + 4] == b"data" { + let len = size.min(bytes.len().saturating_sub(body)); + return Some((body, len)); + } + pos = body + size + (size & 1); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a minimal 16-bit PCM WAV in memory (stereo, 2 frames). + fn tiny_wav() -> Vec { + let mut v = Vec::new(); + let data: [i16; 4] = [1000, -1000, 32767, -32768]; // L R L R + let data_bytes: Vec = data.iter().flat_map(|s| s.to_le_bytes()).collect(); + v.extend_from_slice(b"RIFF"); + v.extend_from_slice(&(36 + data_bytes.len() as u32).to_le_bytes()); + v.extend_from_slice(b"WAVE"); + v.extend_from_slice(b"fmt "); + v.extend_from_slice(&16u32.to_le_bytes()); + v.extend_from_slice(&WAVE_FORMAT_PCM.to_le_bytes()); + v.extend_from_slice(&2u16.to_le_bytes()); // channels + v.extend_from_slice(&48000u32.to_le_bytes()); + v.extend_from_slice(&(48000u32 * 2 * 2).to_le_bytes()); + v.extend_from_slice(&4u16.to_le_bytes()); // block align + v.extend_from_slice(&16u16.to_le_bytes()); // bits + v.extend_from_slice(b"data"); + v.extend_from_slice(&(data_bytes.len() as u32).to_le_bytes()); + v.extend_from_slice(&data_bytes); + v + } + + #[test] + fn probe_and_decode_pcm_wav() { + let wav = tiny_wav(); + let info = AudioInfo::probe(&wav); + assert_eq!(info.codec, AudioCodec::Pcm); + assert_eq!(info.channels, Some(2)); + assert_eq!(info.sample_rate, Some(48000)); + assert_eq!(info.samples_per_channel, Some(2)); + let audio = GameAudio::from_wav(&wav).unwrap(); + assert_eq!(audio.channels, 2); + assert_eq!(audio.samples.len(), 4); + assert!((audio.samples[2] - 0.99997).abs() < 1e-3); // 32767/32768 + } + + #[test] + fn probe_xma2_riff_reports_metadata_not_decode() { + // Minimal RIFF/WAVE with an XMA2 fmt tag. + let mut v = Vec::new(); + v.extend_from_slice(b"RIFF"); + v.extend_from_slice(&200u32.to_le_bytes()); + v.extend_from_slice(b"WAVE"); + v.extend_from_slice(b"fmt "); + v.extend_from_slice(&16u32.to_le_bytes()); + v.extend_from_slice(&WAVE_FORMAT_XMA2.to_le_bytes()); + v.extend_from_slice(&2u16.to_le_bytes()); + v.extend_from_slice(&44100u32.to_le_bytes()); + v.extend_from_slice(&0u32.to_le_bytes()); + v.extend_from_slice(&0u16.to_le_bytes()); + v.extend_from_slice(&0u16.to_le_bytes()); + let info = AudioInfo::probe(&v); + assert_eq!(info.codec, AudioCodec::Xma2); + assert_eq!(info.channels, Some(2)); + assert!(info.codec.needs_decoder()); + assert!(matches!( + GameAudio::from_wav(&v), + Err(AudioError::NeedsDecoder(AudioCodec::Xma2)) + )); + } + + #[test] + fn raw_high_entropy_blob_reads_as_raw_xma2() { + // A pseudo-random 8 KB blob (no magic) → RawXma2 with a packet count. + let mut b = vec![0u8; 8192]; + let mut x = 0x2545_F491u32; + for v in b.iter_mut() { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + *v = (x & 0xFF) as u8; + } + let info = AudioInfo::probe(&b); + assert_eq!(info.codec, AudioCodec::RawXma2); + assert_eq!(info.xma_packets, Some(4)); // 8192 / 2048 + } + + #[test] + fn structured_low_entropy_blob_is_unknown_not_audio() { + let b = b"IDXD............a bunch of readable ASCII text fields....".repeat(40); + assert_eq!(AudioInfo::probe(&b).codec, AudioCodec::Unknown); } } diff --git a/crates/sylpheed-formats/src/idxd.rs b/crates/sylpheed-formats/src/idxd.rs index 896ddf77..7aee93e0 100644 --- a/crates/sylpheed-formats/src/idxd.rs +++ b/crates/sylpheed-formats/src/idxd.rs @@ -183,6 +183,27 @@ impl IdxdObject { } format!("schema {:08x}", self.schema_hash) } + + /// Recover this entry's original TOC path (e.g. `unit\rou_f001.tbl`) from its + /// identity/pool tokens by re-hashing candidates against `name_hash` with the + /// known path schemes. Returns `None` when no scheme reproduces the hash. + /// + /// Shared by the CLI `pak list` and the GUI pack browser so both surface the + /// same recovered names. See [`crate::hash::recover_toc_name`]. + pub fn recover_toc_path(&self, name_hash: u32) -> Option { + let mut cands: Vec<&str> = Vec::new(); + for key in ["ID", "Name", "Model"] { + if let Some(v) = self.get_raw(key) { + cands.push(v); + } + } + for t in self.tokens() { + if t.contains('_') || t.len() >= 5 { + cands.push(t.as_str()); + } + } + crate::hash::recover_toc_name(name_hash, &cands) + } } /// Extract the trailing string pool. Finds the smallest offset whose suffix is diff --git a/crates/sylpheed-formats/src/lib.rs b/crates/sylpheed-formats/src/lib.rs index 72e4b45b..e824c3fc 100644 --- a/crates/sylpheed-formats/src/lib.rs +++ b/crates/sylpheed-formats/src/lib.rs @@ -79,6 +79,7 @@ pub mod unit_layout; pub mod ship_capture; /// Re-export the most commonly used types at the crate root. +pub use audio::{AudioCodec, AudioInfo, GameAudio}; pub use font::FontInfo; pub use idxd::{IdxdError, IdxdObject}; pub use ixud::{Cue, Subtitle}; diff --git a/crates/sylpheed-formats/src/texture.rs b/crates/sylpheed-formats/src/texture.rs index f2afd499..4d791bb4 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 ac1abedc..85cd4b59 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -132,6 +132,9 @@ pub struct PakRow { pub format: String, /// Short identity (`ID=…` / `Name=…`), empty for non-IDXD entries. pub identity: String, + /// Recovered original TOC path (e.g. `unit\rou_f001.tbl`) via the name-hash, + /// when a known scheme reproduces `hash`. `None` = unresolved (~70% of entries). + pub name: Option, /// Parsed IDXD detail for the property table, when the entry is an object. pub detail: Option, /// Decoded presentable payload for the detail pane (subtitle / font / image @@ -165,6 +168,8 @@ pub enum PakContent { Ratc(Vec, Option), /// Plain-text / XML payload, with its encoding label. Text { text: String, encoding: String }, + /// An audio stream (WAV / XMA / raw XMA2) — metadata only; XMA isn't decoded. + Audio(sylpheed_formats::AudioInfo), } /// One child in a presented RATC bundle. @@ -1325,6 +1330,7 @@ fn build_pak_rows(index: &[u8], data: Vec) -> Result, String> { size: e.comp_size as usize, format: "(not decoded)".into(), identity: String::new(), + name: None, detail: None, content: PakContent::None, }); @@ -1334,7 +1340,7 @@ fn build_pak_rows(index: &[u8], data: Vec) -> Result, String> { Ok(payload) => { decoded_budget += payload.len(); let format = inner_format_label(&payload); - let (identity, detail) = if IdxdObject::is_idxd(&payload) { + let (identity, name, detail) = if IdxdObject::is_idxd(&payload) { match IdxdObject::parse(&payload) { Ok(obj) => { let fields = obj @@ -1344,6 +1350,7 @@ fn build_pak_rows(index: &[u8], data: Vec) -> Result, String> { .collect(); ( obj.identity(), + obj.recover_toc_path(e.name_hash), Some(PakDetail { schema_hash: obj.schema_hash, count: obj.count, @@ -1351,10 +1358,10 @@ fn build_pak_rows(index: &[u8], data: Vec) -> Result, String> { }), ) } - Err(_) => (String::new(), None), + Err(_) => (String::new(), None, None), } } else { - (String::new(), None) + (String::new(), None, None) }; // Presentable content for non-IDXD entries (subtitle/font/png/text). let content = if detail.is_some() { @@ -1368,6 +1375,7 @@ fn build_pak_rows(index: &[u8], data: Vec) -> Result, String> { size: payload.len(), format, identity, + name, detail, content, }); @@ -1378,6 +1386,7 @@ fn build_pak_rows(index: &[u8], data: Vec) -> Result, String> { size: e.comp_size as usize, format: "(read error)".into(), identity: err.to_string(), + name: None, detail: None, content: PakContent::None, }), @@ -1500,6 +1509,12 @@ fn classify_content(payload: &[u8]) -> PakContent { encoding: encoding.to_string(), }; } + // Last resort: a self-describing WAV/XMA header, or a header-less high- + // entropy blob (the game's raw XMA2 sound-bank streams). + let audio = sylpheed_formats::AudioInfo::probe(payload); + if audio.codec != sylpheed_formats::AudioCodec::Unknown { + return PakContent::Audio(audio); + } PakContent::None } @@ -2356,9 +2371,23 @@ fn prepare_xpr(bytes: &[u8], cancel: &Arc) -> PreparedXpr { Ok(t) => t, Err(e) => return PreparedXpr::Info(format!("XPR2 parse failed: {e}")), }; + // Canary-sourced GPUTEXTUREFORMAT name + metadata, e.g. + // "k_DXT1 (Dxt1) · 256×256 · 9 mip(s) · 4 bpp, compressed". Naming the + // format the way the console does makes a decode mismatch legible. + let d = tex.format.desc(); let info = format!( - "{:?} {}×{} {} mip(s)", - tex.format, tex.width, tex.height, tex.mip_levels + "{} ({:?}) · {}×{} · {} mip(s) · {} bpp, {}", + tex.format.gpu_name(), + tex.format, + tex.width, + tex.height, + tex.mip_levels, + d.bpp, + if d.compressed { + "compressed" + } else { + "uncompressed" + }, ); match crate::asset_loader::x360_texture_to_bevy_image(tex) { Ok(image) => PreparedXpr::Texture { image, info }, diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index 955d4f2d..ef254c7e 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -559,7 +559,7 @@ fn draw_viewer_ui( // No file selected — show welcome / RE notes egui::CentralPanel::default().show(ctx, |ui| { ui.heading("Project Sylpheed: Arc of Deception"); - ui.label("Asset Viewer — Milestone 1"); + ui.label("Asset Viewer · Game Arts / SETA · Square Enix (Xbox 360)"); ui.separator(); ui.collapsing("Getting Started", |ui| { @@ -570,37 +570,48 @@ fn draw_viewer_ui( ui.code(" xdvdfs unpack sylpheed.iso ./assets/"); ui.code(" File → Open extracted folder…"); ui.separator(); - ui.label("Click a .xpr file in the left panel to preview the texture."); + ui.label( + "Open a .pak to browse its entries, a .xpr for textures, \ + an .xbg mesh for the 3D preview, or a movie .wmv to play it.", + ); }); - ui.collapsing("Reverse Engineering Notes", |ui| { - ui.label("Document your findings here as you RE the formats."); - ui.separator(); - egui::Grid::new("re_notes").striped(true).show(ui, |ui| { - ui.strong("Extension"); + ui.collapsing("Reverse-engineered formats", |ui| { + let done = egui::Color32::LIGHT_GREEN; + let partial = egui::Color32::from_rgb(150, 210, 255); + let todo = egui::Color32::YELLOW; + egui::Grid::new("re_notes").striped(true).num_columns(3).show(ui, |ui| { + ui.strong("Format"); ui.strong("Status"); ui.strong("Notes"); ui.end_row(); - ui.label(".xpr"); - ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Partial"); - ui.label("XPR2 container + DXT1/3/5 textures"); - ui.end_row(); - - ui.label("?"); - ui.colored_label(egui::Color32::YELLOW, "⏳ TODO"); - ui.label("Mesh format — need to identify extension"); - ui.end_row(); - - ui.label("?"); - ui.colored_label(egui::Color32::YELLOW, "⏳ TODO"); - ui.label("Audio format — likely XWB wave banks"); - ui.end_row(); - - ui.label("?"); - ui.colored_label(egui::Color32::YELLOW, "⏳ TODO"); - ui.label("Mission/level data — format unknown"); - ui.end_row(); + let mut row = |ui: &mut egui::Ui, fmt: &str, c, status: &str, notes: &str| { + ui.label(fmt); + ui.colored_label(c, status); + ui.label(notes); + ui.end_row(); + }; + row(ui, "IPFB .pak/.pNN", done, "✓ done", + "Archive TOC + name-hash recovered (paths resolved)"); + row(ui, "IDXD objects", partial, "◑ most", + "Reflective ship/weapon/effect defs; some fields defaulted in-code"); + row(ui, "XBG7 mesh", done, "✓ done", + "3D models — position + normal + UV from vertex decl"); + row(ui, "XPR2 texture", done, "✓ done", + "A8R8G8B8 / DXT1/3/5, de-tiled; 2D + cubemaps"); + row(ui, "T8aD / RATC / LSTA", partial, "◑ most", + "2D UI textures, bundles, sprite lists (colours unverified)"); + row(ui, "IXUD subtitles", done, "✓ done", + "Localized movie subtitle cue tables"); + row(ui, "Font (OTF/TTF/ttcf)", done, "✓ done", + "Embedded subtitle fonts — metadata + glyph sample"); + row(ui, "WMV cutscene", done, "✓ done", + "wmv3 / wmapro playback with transport + subtitles"); + row(ui, "XISO disc", done, "✓ done", + "XDVDFS filesystem — direct ISO browsing"); + row(ui, "XMA audio", todo, "⏳ wip", + "Xbox 360 XMA → PCM (scaffold)"); }); }); }); @@ -641,13 +652,17 @@ fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) { .id_salt("pak_list") .show(&mut cols[0], |ui| { for (i, row) in rows.iter().enumerate() { - let text = egui::RichText::new(format!( + // Prefer the recovered TOC path (name-hash) over the generic + // kind hint; colour resolved names green so they stand out. + let label = row.name.clone().unwrap_or_else(|| row_kind(row)); + let mut text = egui::RichText::new(format!( "{:08x} {:<7} {}", - row.hash, - row.format, - row_kind(row) + row.hash, row.format, label )) .monospace(); + if row.name.is_some() { + text = text.color(egui::Color32::LIGHT_GREEN); + } if ui .add(egui::SelectableLabel::new(*selected == Some(i), text)) .clicked() @@ -678,6 +693,7 @@ fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) { PakContent::Text { text, encoding } => { draw_text_detail(ui, row, text, encoding) } + PakContent::Audio(info) => draw_audio_detail(ui, row, info), PakContent::None => draw_idxd_detail(ui, row), }, }); @@ -706,6 +722,10 @@ fn row_kind(row: &crate::iso_loader::PakRow) -> String { format!("RATC · {} item(s){s}", c.len()) } PakContent::Text { .. } => "text".into(), + PakContent::Audio(a) => match a.xma_packets { + Some(p) => format!("audio · {} · {p} pkts", a.codec.label()), + None => format!("audio · {}", a.codec.label()), + }, PakContent::None => row.identity.clone(), } } @@ -726,6 +746,10 @@ fn draw_idxd_detail(ui: &mut egui::Ui, row: &crate::iso_loader::PakRow) { row.identity.as_str() }; ui.heading(title); + // Recovered original path (name-hash) — the entry's real TOC key. + if let Some(name) = &row.name { + ui.colored_label(egui::Color32::LIGHT_GREEN, format!("📄 {name}")); + } ui.label(format!("schema 0x{:08x} count {}", d.schema_hash, d.count)); ui.label(format!("{} bytes hash {:08x}", row.size, row.hash)); ui.separator(); @@ -1033,6 +1057,59 @@ fn draw_text_detail( ); } +/// An audio stream entry: codec + whatever metadata is derivable. The game's +/// `sound.pak` streams are raw XMA2 (no embedded channels/rate), so most fields +/// read "—" until the sound-bank descriptor + an XMA2 decoder are added. +fn draw_audio_detail( + ui: &mut egui::Ui, + row: &crate::iso_loader::PakRow, + info: &sylpheed_formats::AudioInfo, +) { + ui.horizontal(|ui| { + ui.heading("Audio stream"); + ui.separator(); + ui.label(info.codec.label()); + ui.separator(); + ui.label(format!("hash {:08x}", row.hash)); + }); + ui.separator(); + + let dash = "—".to_string(); + egui::Grid::new("audio_meta").striped(true).num_columns(2).show(ui, |ui| { + ui.strong("Codec"); + ui.label(info.codec.label()); + ui.end_row(); + ui.strong("Channels"); + ui.label(info.channels.map(|c| c.to_string()).unwrap_or_else(|| dash.clone())); + ui.end_row(); + ui.strong("Sample rate"); + ui.label(info.sample_rate.map(|r| format!("{r} Hz")).unwrap_or_else(|| dash.clone())); + ui.end_row(); + if let Some(d) = info.duration_secs { + ui.strong("Duration"); + ui.label(format!("{d:.2} s")); + ui.end_row(); + } + if let Some(p) = info.xma_packets { + ui.strong("XMA packets"); + ui.label(format!("{p} (2048 B each)")); + ui.end_row(); + } + ui.strong("Size"); + ui.label(format!("{} bytes", info.size_bytes)); + ui.end_row(); + }); + + if info.codec.needs_decoder() { + ui.separator(); + ui.colored_label( + egui::Color32::from_gray(150), + "Raw XMA2 stream — playback needs an XMA2 decoder and the (un-RE'd) \ + sound-bank descriptor for per-stream channels/sample-rate.", + ); + } +} + // ── Video player (WMV cutscenes) ────────────────────────────────────────────── /// Format seconds as `mm:ss`.