//! `.slb` XACT sound banks → a decodable XMA1 `RIFF`. //! //! `dat/sound.pak` (9519 entries across `sound.p00..p04`) is the game's audio //! bank. Each entry is an XACT `.slb` wrapping **XMA1** (`fmt ` tag `0x0165`, //! 48 kHz). Files are named `\Voice\…`, `\Movie\VOICE_.slb`, //! `BGM_###.slb`, etc. (see `docs/re/structures/sound-slb.md`); look them up by //! [`crate::hash::name_hash`]. Movie cutscene voice = one continuous //! `\Movie\VOICE_.slb` track meant to play from the video start. //! //! Two on-disc layouts, both reversed statically: //! - **RIFF present** — a standard `RIFF/WAVE` sits inside the bank; its 32-byte //! `fmt ` is the real `XMAWAVEFORMAT` and the XMA packets are everything after //! that RIFF's `data` chunk header (the declared `data` size is unreliable, so //! we take to end and let the caller clamp to the known media length). //! - **Headerless** — no RIFF at all; a fixed **1392-byte** header precedes raw //! XMA1 packets (48 kHz, 2 channels). //! //! [`to_xma_riff`] rebuilds a standalone `RIFF/WAVE` (XMA1) for either layout, //! ready to hand to an XMA decoder (e.g. FFmpeg's `xma1`). The content is always //! mono (some clips put it in the left channel only, others duplicate L=R), so //! the decode step should downmix to mono (take the left channel). /// Fixed offset of the raw XMA1 stream in a headerless `.slb` (no `RIFF`). pub const HEADERLESS_DATA_OFFSET: usize = 1392; /// Voice language for cutscene audio. Only English and Japanese voice exist on /// the disc (subtitles cover more languages, voice does not). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VoiceLang { English, Japanese, } impl VoiceLang { fn code(self) -> &'static str { match self { VoiceLang::English => "eng", VoiceLang::Japanese => "jpn", } } /// `eng` / `jpn` — the `sound.pak` path prefix for this voice language. pub fn code_pub(self) -> &'static str { self.code() } } /// The `sound.pak` entry name for a movie's continuous voice track, e.g. /// `eng\Movie\VOICE_RT07A.slb`. Hash it with [`crate::hash::name_hash`] to get /// the `sound.pak` TOC key. pub fn movie_voice_name(movie_basename: &str, lang: VoiceLang) -> String { format!("{}\\Movie\\VOICE_{}.slb", lang.code(), movie_basename) } /// One playable voice clip discovered in `sounds.tbl`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VoiceClip { /// Full `sound.pak` entry name, e.g. `eng\Voice\VOICE_ADAN_010.slb`. pub name: String, /// Speaker/category code, e.g. `ADAN`, `TCAF`, `A`, `RT07A`. pub speaker: String, /// Short UI label, e.g. `ADAN 010`. pub display: String, } /// Enumerate the voice/dialog clips named in a decompressed `sounds.tbl` (the /// IDXD in `tables.pak`). Extracts every `\{Voice,etc,Movie,Briefing}\…` /// path ending in `.slb` for `lang`, parsed into `(name, speaker, display)`. pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec { let prefix = format!("{}\\", lang.code()); let mut seen = std::collections::BTreeSet::new(); let mut out = Vec::new(); // Scan for printable-ASCII runs; keep those that look like a voice path. let mut i = 0; while i < sounds_tbl.len() { let start = i; while i < sounds_tbl.len() && (0x20..=0x7e).contains(&sounds_tbl[i]) { i += 1; } if i - start >= 6 { if let Ok(s) = std::str::from_utf8(&sounds_tbl[start..i]) { if s.starts_with(&prefix) && s.ends_with(".slb") && s.contains("VOICE") { if seen.insert(s.to_string()) { out.push(parse_voice_clip(s)); } } } } i += 1; } out } fn parse_voice_clip(name: &str) -> VoiceClip { // `\\VOICE__.slb` or `..\VOICE_.slb`. let stem = name .rsplit('\\') .next() .unwrap_or(name) .strip_suffix(".slb") .unwrap_or(name); let body = stem.strip_prefix("VOICE_").unwrap_or(stem); let (speaker, display) = match body.rsplit_once('_') { Some((spk, num)) if num.chars().all(|c| c.is_ascii_digit()) => { (spk.to_string(), format!("{spk} {num}")) } _ => (body.to_string(), body.to_string()), }; VoiceClip { name: name.to_string(), speaker, display, } } /// Build a standalone XMA1 `RIFF/WAVE` from a `.slb` bank, decodable by FFmpeg's /// `xma1`. Returns `None` if the bank is too small / malformed. pub fn to_xma_riff(slb: &[u8]) -> Option> { if let Some(ri) = find(slb, b"RIFF", 0) { // RIFF layout: a `.slb` is an XACT bank of one or more sub-waves, each // `[seek][RIFF: fmt + Dmmy pad + data][declared_size XMA bytes]`. Take the // FIRST sub-wave, bounded by its **declared `data` size** (which is // honest per sub-wave). Decoding to end-of-file instead would append the // later sub-waves — for multi-take story movies those are ALTERNATE takes, // which is what made S10–S16 play the wrong audio. let fi = find(slb, b"fmt ", ri)?; let fsz = le32(slb, fi + 4)? as usize; let fmt_end = fi.checked_add(8)?.checked_add(fsz)?; if fmt_end > slb.len() { return None; } let fmt_chunk = &slb[fi..fmt_end]; let di = find(slb, b"data", fi)?; let dsz = le32(slb, di + 4)? as usize; let end = di.checked_add(8)?.checked_add(dsz)?.min(slb.len()); let data = slb.get(di + 8..end)?; Some(build_riff(fmt_chunk, data)) } else { // Headerless: 1392-byte header, then raw XMA1 (48 kHz, 2 channels). let data = slb.get(HEADERLESS_DATA_OFFSET..)?; if data.is_empty() { return None; } Some(build_riff(&synth_xma1_fmt(2, 2, 48000), data)) } } /// A minimal `fmt ` chunk carrying an XMA1 `XMAWAVEFORMAT` (one stream). fn synth_xma1_fmt(channels: u8, channel_mask: u16, rate: u32) -> Vec { let mut fmt = Vec::with_capacity(40); fmt.extend_from_slice(b"fmt "); fmt.extend_from_slice(&32u32.to_le_bytes()); // XMAWAVEFORMAT header fmt.extend_from_slice(&0x0165u16.to_le_bytes()); // wFormatTag = XMA1 fmt.extend_from_slice(&16u16.to_le_bytes()); // BitsPerSample fmt.extend_from_slice(&0u16.to_le_bytes()); // EncodeOptions fmt.extend_from_slice(&0u16.to_le_bytes()); // LargestSkip fmt.extend_from_slice(&1u16.to_le_bytes()); // NumStreams fmt.push(0); // LoopCount fmt.push(3); // Version // XMASTREAMFORMAT[0] fmt.extend_from_slice(&(rate * channels as u32 * 2).to_le_bytes()); // PsuedoBytesPerSec fmt.extend_from_slice(&rate.to_le_bytes()); // SampleRate fmt.extend_from_slice(&0u32.to_le_bytes()); // LoopStart fmt.extend_from_slice(&0u32.to_le_bytes()); // LoopEnd fmt.push(4); // SubframeData fmt.push(channels); // Channels fmt.extend_from_slice(&channel_mask.to_le_bytes()); // ChannelMask fmt } fn build_riff(fmt_chunk: &[u8], data: &[u8]) -> Vec { let mut body = Vec::with_capacity(4 + fmt_chunk.len() + 8 + data.len()); body.extend_from_slice(b"WAVE"); body.extend_from_slice(fmt_chunk); body.extend_from_slice(b"data"); body.extend_from_slice(&(data.len() as u32).to_le_bytes()); body.extend_from_slice(data); let mut out = Vec::with_capacity(8 + body.len()); out.extend_from_slice(b"RIFF"); out.extend_from_slice(&(body.len() as u32).to_le_bytes()); out.extend_from_slice(&body); out } fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { if from >= hay.len() { return None; } hay[from..] .windows(needle.len()) .position(|w| w == needle) .map(|p| p + from) } fn le32(b: &[u8], o: usize) -> Option { let s = b.get(o..o + 4)?; Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]])) } #[cfg(test)] mod tests { use super::*; use crate::hash::name_hash; #[test] fn movie_voice_names_and_hashes() { assert_eq!( movie_voice_name("RT07A", VoiceLang::English), "eng\\Movie\\VOICE_RT07A.slb" ); // Verified present in dat/sound.pak (VOICE_S00A == TOC entry 0x2A4F97D3). assert_eq!(name_hash("eng\\Movie\\VOICE_RT07A.slb"), 0xA44A_EA1C); assert_eq!(name_hash("eng\\Movie\\VOICE_S00A.slb"), 0x2A4F_97D3); } #[test] fn takes_only_first_subwave() { // Bank of TWO sub-waves; only the FIRST must be extracted (bounded by its // declared `data` size), not everything to end-of-file. let fmt = synth_xma1_fmt(2, 2, 48000); let mut slb = vec![0xAB; 16]; slb.extend_from_slice(b"RIFF"); slb.extend_from_slice(&999u32.to_le_bytes()); // riff size (ignored) slb.extend_from_slice(b"WAVE"); slb.extend_from_slice(&fmt); slb.extend_from_slice(b"data"); slb.extend_from_slice(&4u32.to_le_bytes()); // declared: 4 bytes slb.extend_from_slice(&[1, 2, 3, 4]); // sub-wave 1 XMA slb.extend_from_slice(&[9, 9, 9, 9]); // a second sub-wave's bytes — excluded let riff = to_xma_riff(&slb).unwrap(); assert_eq!(&riff[0..4], b"RIFF"); let dpos = find(&riff, b"data", 0).unwrap(); assert_eq!(le32(&riff, dpos + 4), Some(4)); assert_eq!(&riff[dpos + 8..], &[1, 2, 3, 4]); } #[test] fn rebuilds_riff_from_headerless() { let mut slb = vec![0u8; HEADERLESS_DATA_OFFSET]; slb.extend_from_slice(&[9, 8, 7, 6]); let riff = to_xma_riff(&slb).unwrap(); let dpos = find(&riff, b"data", 0).unwrap(); assert_eq!(&riff[dpos + 8..], &[9, 8, 7, 6]); // synthetic fmt advertises XMA1. let fpos = find(&riff, b"fmt ", 0).unwrap(); assert_eq!(le32(&riff, fpos + 8).map(|v| v as u16), Some(0x0165)); } }