Compare commits
3 Commits
auto/re-un
...
archive/lo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ed8c85f18 | ||
|
|
3e1040b472 | ||
|
|
390c67cb61 |
@@ -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)]
|
||||
@@ -202,9 +217,41 @@ async fn main() -> Result<()> {
|
||||
PakCommands::List { pak, idxd_only } => cmd_pak_list(&pak, idxd_only),
|
||||
PakCommands::Dump { pak, hash } => cmd_pak_dump(&pak, &hash),
|
||||
},
|
||||
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<String>| 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<()> {
|
||||
@@ -359,9 +406,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());
|
||||
|
||||
@@ -707,24 +761,6 @@ fn decode_to_rgba8(tex: &sylpheed_formats::texture::X360Texture) -> Result<Vec<u
|
||||
|
||||
use sylpheed_formats::pak::inner_format_label as inner_label;
|
||||
|
||||
/// Try to recover an IDXD entry's original TOC path from its identity tokens.
|
||||
/// Uses the entry's ID/Name/Model fields plus identifier-like pool tokens as
|
||||
/// candidates for [`sylpheed_formats::hash::recover_toc_name`].
|
||||
fn idxd_toc_name(obj: &IdxdObject, name_hash: u32) -> Option<String> {
|
||||
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!(
|
||||
@@ -752,7 +788,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 {
|
||||
|
||||
@@ -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<u16>,
|
||||
pub sample_rate: Option<u32>,
|
||||
pub bits_per_sample: Option<u16>,
|
||||
/// PCM samples per channel, when derivable (WAV only).
|
||||
pub samples_per_channel: Option<u64>,
|
||||
pub duration_secs: Option<f32>,
|
||||
pub size_bytes: usize,
|
||||
/// 2048-byte XMA packet count, for XMA/raw-XMA streams.
|
||||
pub xma_packets: Option<u32>,
|
||||
}
|
||||
|
||||
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<AudioInfo> {
|
||||
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<u64> = 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<f32>,
|
||||
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<Self, AudioError> {
|
||||
// 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<Self, AudioError> {
|
||||
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<Self, AudioError> {
|
||||
// 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<f32> = 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<u8> {
|
||||
let mut v = Vec::new();
|
||||
let data: [i16; 4] = [1000, -1000, 32767, -32768]; // L R L R
|
||||
let data_bytes: Vec<u8> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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
|
||||
|
||||
@@ -51,6 +51,7 @@ pub mod mesh;
|
||||
pub mod audio;
|
||||
|
||||
/// 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};
|
||||
|
||||
@@ -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<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 ─────────────────────────────────────────────────────
|
||||
@@ -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<Self, TextureError> {
|
||||
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<Option<Cubemap>, 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));
|
||||
|
||||
@@ -130,6 +130,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<String>,
|
||||
/// Parsed IDXD detail for the property table, when the entry is an object.
|
||||
pub detail: Option<PakDetail>,
|
||||
/// Decoded presentable payload for the detail pane (subtitle / font / image
|
||||
@@ -162,6 +165,8 @@ pub enum PakContent {
|
||||
Ratc(Vec<RatcEntry>),
|
||||
/// 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.
|
||||
@@ -799,6 +804,7 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
|
||||
size: e.comp_size as usize,
|
||||
format: "(not decoded)".into(),
|
||||
identity: String::new(),
|
||||
name: None,
|
||||
detail: None,
|
||||
content: PakContent::None,
|
||||
});
|
||||
@@ -808,7 +814,7 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, 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
|
||||
@@ -818,6 +824,7 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
|
||||
.collect();
|
||||
(
|
||||
obj.identity(),
|
||||
obj.recover_toc_path(e.name_hash),
|
||||
Some(PakDetail {
|
||||
schema_hash: obj.schema_hash,
|
||||
count: obj.count,
|
||||
@@ -825,10 +832,10 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, 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() {
|
||||
@@ -842,6 +849,7 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
|
||||
size: payload.len(),
|
||||
format,
|
||||
identity,
|
||||
name,
|
||||
detail,
|
||||
content,
|
||||
});
|
||||
@@ -852,6 +860,7 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
|
||||
size: e.comp_size as usize,
|
||||
format: "(read error)".into(),
|
||||
identity: err.to_string(),
|
||||
name: None,
|
||||
detail: None,
|
||||
content: PakContent::None,
|
||||
}),
|
||||
@@ -971,6 +980,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
|
||||
}
|
||||
|
||||
@@ -1986,7 +2001,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,
|
||||
|
||||
@@ -374,7 +374,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| {
|
||||
@@ -385,37 +385,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)");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -456,13 +467,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()
|
||||
@@ -491,6 +506,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),
|
||||
},
|
||||
});
|
||||
@@ -516,6 +532,10 @@ fn row_kind(row: &crate::iso_loader::PakRow) -> String {
|
||||
PakContent::Lsta(f) => format!("LSTA · {} sprite(s)", f.len()),
|
||||
PakContent::Ratc(c) => format!("RATC · {} item(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(),
|
||||
}
|
||||
}
|
||||
@@ -536,6 +556,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();
|
||||
@@ -817,6 +841,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`.
|
||||
|
||||
Reference in New Issue
Block a user