//! `.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; /// XMA1 packet size. A headerless stream is always a whole number of these, which /// is how a leading stream is told apart from arbitrary bytes before a `RIFF`. pub const XMA1_PACKET: usize = 2048; /// 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]) { // Every spoken-line category, so the standalone player covers them // all: in-mission radio (`\Voice\`, `\etc\`) and bound movie voices // (`\Movie\`) all carry `VOICE_`; mission-briefing lines live in // `\Briefing\` as `BR_.slb` (no `VOICE` in the name). let is_voice = s.contains("VOICE") || s.contains("\\Briefing\\"); if s.starts_with(&prefix) && s.ends_with(".slb") && is_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 one standalone XMA1 `RIFF/WAVE` **per sub-wave** in a `.slb` bank, in /// on-disc order. A `.slb` is an XACT bank of one or more sub-waves; the sub-waves /// are EITHER alternate takes (the first is the whole track — e.g. `VOICE_S01A`, /// `VOICE_ADV`: sub0 ≈ the movie length) OR sequential segments that must be /// concatenated (e.g. `VOICE_RT07A`: 24s + 14s + 11s ≈ the 50s movie). The caller /// decodes each to PCM, concatenates in order, and clamps to the movie length — /// that yields the full track for segment banks while the clamp drops the /// duplicate takes for alternate-take banks. (Dynamic RE via Canary file-I/O /// tracing confirmed the movie→voice binding; this fixes the *decode* of `RT*`.) /// A bank's channel count, read from its first `RIFF` sub-wave. /// /// `XMASTREAMFORMAT.Channels` sits at `RIFF + 49`. **2.12 % of banks are stereo** /// (170 of 8 021), and decoding one of those as mono yields a single frame and /// stops — the same signature already recorded for the leading segment. So the /// channel count has to be read, not assumed. Returns `None` when there is no /// `RIFF` to read it from. fn riff_channels(slb: &[u8]) -> Option { let ri = find(slb, b"RIFF", 0)?; slb.get(ri + 49).copied().filter(|c| *c == 1 || *c == 2) } /// The four data offsets that occur on the disc, in ascending order. /// /// Measured over all 7 358 banks whose offset is *known* (they carry a `RIFF`, /// so the offset is forced to `first_riff % XMA1_PACKET`): no other value /// occurs. They are all of the form `1392 + 4k`. pub const DATA_OFFSET_CANDIDATES: [usize; 4] = [1392, 1468, 1600, 1728]; /// How plausible a candidate offset is, judged by XMA1 packet headers alone. /// /// Each 2048-byte packet opens with a big-endian header: 6 bits frame count, /// 15 bits frame-offset-in-bits, 3 bits metadata, 8 bits packet-skip. At the /// true offset those fields stay in range packet after packet; one byte off and /// they do not. Returns the fraction of the first `LIMIT` packets that look /// sane, so 1.0 is a clean stream. fn packet_plausibility(slb: &[u8], start: usize) -> f32 { const LIMIT: usize = 24; let (mut seen, mut ok, mut pos) = (0usize, 0usize, start); while pos + XMA1_PACKET <= slb.len() && seen < LIMIT { let h = u32::from_be_bytes([slb[pos], slb[pos + 1], slb[pos + 2], slb[pos + 3]]); let frame_offset_bits = (h >> 11) & 0x7FFF; let metadata = (h >> 8) & 0x7; let packet_skip = h & 0xFF; if frame_offset_bits as usize <= XMA1_PACKET * 8 && metadata <= 1 && packet_skip <= 8 { ok += 1; } seen += 1; pos += XMA1_PACKET; } if seen == 0 { 0.0 } else { ok as f32 / seen as f32 } } /// The data offset implied by the bank's `seek` chunk, if it has one. /// /// A bank's `seek` chunk lands on a packet boundary, so `seek_pos % XMA1_PACKET` /// *is* the data offset. Measured on the 6 033 labelled banks that have a /// `seek` before their first `RIFF`: **6 031 agree (99.97 %)**, 2 disagree. /// This is structural rather than statistical, which is why it is tried first. fn seek_chunk_offset(slb: &[u8]) -> Option { let pos = find(slb, b"seek", 0)?; let residue = pos % XMA1_PACKET; DATA_OFFSET_CANDIDATES.contains(&residue).then_some(residue) } /// Recover a bank's data offset when there is no `RIFF` to derive it from. /// /// Two independent signals, tried in order of how well each is evidenced: /// /// 1. **the `seek` chunk's position** mod the packet size — 99.97 % on the /// labelled set, and structural rather than statistical; /// 2. **packet-header plausibility** — pick the candidate whose XMA1 headers /// stay in range over the first 24 packets. Alone this is 99.62 %, and all /// 28 of its misses are ties rather than wrong unique winners. /// /// Together, on the 7 358 banks where the answer *is* known from the `RIFF` /// position: **7 354 correct (99.95 %)**. The `seek` residue resolves 26 of the /// scan's 28 ties correctly and none of them wrongly; the other 2 have no /// usable `seek`. Falls back to [`HEADERLESS_DATA_OFFSET`] when neither signal /// decides. pub fn scan_data_offset(slb: &[u8]) -> usize { if let Some(off) = seek_chunk_offset(slb) { return off; } let mut best = (HEADERLESS_DATA_OFFSET, -1.0f32); let mut tied = false; for &c in &DATA_OFFSET_CANDIDATES { if c >= slb.len() { continue; } let score = packet_plausibility(slb, c); if score > best.1 { best = (c, score); tied = false; } else if (score - best.1).abs() < f32::EPSILON { tied = true; } } if tied { HEADERLESS_DATA_OFFSET } else { best.0 } } /// Where a bank's leading headerless packet stream starts. /// /// The stream is a whole number of 2048-byte XMA1 packets ending at the first /// `RIFF`, so its start is simply `first_riff % XMA1_PACKET`. Disc-wide that /// lands on 1392, 1468, 1600 or 1728 depending on language and subdirectory — /// [`HEADERLESS_DATA_OFFSET`] is just the `\etc\` case. Measured over a /// 140-bank sample, deriving the offset instead of assuming 1392 recovers a /// median **70×** more decoded audio and never less except in one bank where /// neither offset decodes (see `docs/re/structures/slb-data-offset.md`). pub fn leading_data_offset(first_riff: usize) -> usize { first_riff % XMA1_PACKET } pub fn to_xma_riffs(slb: &[u8]) -> Vec> { let mut out = Vec::new(); let first_riff = find(slb, b"RIFF", 0); if first_riff.is_none() { // Headerless single-stream bank. Two things here were wrong, and the // corrections are measured (docs/re/structures/slb-data-offset.md): // // * the offset is not the constant — with no `RIFF` to derive it from, // scan the four candidates by packet plausibility; // * the stream is **mono**. At two channels a 48-bank sample yielded // 0..4 816 bytes; at one, 180 000..380 000. There was not one bank // where the old stereo/1392 pair beat the scanned mono pair, and the // median gain was 184x. let start = scan_data_offset(slb); if let Some(data) = slb.get(start..) { if !data.is_empty() { out.push(build_riff(&synth_xma1_fmt(1, 0, 48000), data)); } } return out; } // HYBRID banks: a headerless packet stream followed by RIFF sub-waves, two // SEQUENTIAL SEGMENTS of one clip. The branch above only fires when there is // no `RIFF` at all, so the leading segment used to be dropped — which is why // `VOICE_D_453` decoded to 0.14 s: its line is in that segment and only the // trailing fragment survived. // // The boundary is arithmetic, not a magic: XMA1 packets are 2048 bytes, so a // leading stream is a whole number of packets ending at the first `RIFF`. // Its START is therefore `first_riff % XMA1_PACKET` — **not** the constant // `HEADERLESS_DATA_OFFSET`, which is only the value that offset happens to // take in `\etc\`. Disc-wide it takes four values (1392, 1468, 1600, // 1728), varying by language and subdirectory, and assuming 1392 starts the // decode mid-packet everywhere else. See docs/re/structures/slb-data-offset.md. // It decodes as **mono** — at two channels every bank yields exactly 1792 // bytes, one frame, whatever its size. // // An earlier version of this was withdrawn for two good reasons, both now // answered: it recovered no audio (it used the stereo format), and it // matched 1524 of the 8021 RIFF-bearing entries. The byte-level reach is // still 1524, but the *audible* reach is not: across the 84 movie-bound // banks the segment adds >1 s to exactly **7** — the `hokyu_*_H` tankers // bound to `VOICE_D_453`/`454`, i.e. precisely the broken ones — and // ≤0.25 s to 66 of the rest. Callers clamp to the movie length anyway. if let Some(ri) = first_riff { let start = leading_data_offset(ri); if ri > start { if let Some(data) = slb.get(start..ri) { if data.iter().any(|b| *b != 0) { // Channels come from the bank's own `fmt `, not a constant: // 170 of 8 021 banks are stereo and decode to one frame if // forced to mono. let ch = riff_channels(slb).unwrap_or(1); let mask = if ch == 2 { 2 } else { 0 }; out.push(build_riff(&synth_xma1_fmt(ch, mask, 48000), data)); } } } } let mut pos = 0usize; while let Some(ri) = find(slb, b"RIFF", pos) { // Parse this sub-wave's fmt + data. The declared `data` size is an // UPPER bound, not an exact one: 5 296 of 7 586 banks declare more than // the entry holds and none declares exactly what it holds, so the clamp // below is load-bearing (docs/re/structures/slb-data-offset.md). let Some(fi) = find(slb, b"fmt ", ri) else { break }; let Some(fsz) = le32(slb, fi + 4) else { break }; let Some(fmt_end) = fi.checked_add(8).and_then(|v| v.checked_add(fsz as usize)) else { break; }; if fmt_end > slb.len() { break; } let Some(di) = find(slb, b"data", fi) else { break }; let Some(dsz) = le32(slb, di + 4) else { break }; let Some(ds) = di.checked_add(8) else { break }; let de = ds .checked_add(dsz as usize) .unwrap_or(slb.len()) .min(slb.len()); if let Some(data) = slb.get(ds..de) { if !data.is_empty() { out.push(build_riff(&slb[fi..fmt_end], data)); } } // Advance past this sub-wave's data to find the next RIFF. pos = de.max(ri + 4); } out } /// Build a standalone XMA1 `RIFF/WAVE` from a `.slb` bank, decodable by FFmpeg's /// `xma1`. Returns `None` if the bank is too small / malformed. This is the /// FIRST sub-wave only; prefer [`to_xma_riffs`] for correct multi-segment banks. 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 an // upper bound only (69.8 % of banks over-declare it, so the clamp // matters), but still the right boundary to cut at. 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)) } } /// Rebuild a standalone XMA1 `RIFF/WAVE` from a single-stream `.slb`, robust to /// the layout variants seen in `\etc\` radio clips. Unlike [`to_xma_riff`] /// (which assumes `RIFF → fmt → data` in order), this picks the **largest `data` /// chunk anywhere** in the bank — some radio banks store the audio *before* the /// trailing `RIFF`/`fmt` metadata (an empty post-`RIFF` `data` chunk), which the /// ordered scan misses. Pairs it with the first `fmt ` chunk; falls back to the /// headerless layout. Returns `None` only when no usable audio can be found. pub fn to_xma_riff_best(slb: &[u8]) -> Option> { // Largest usable `data` chunk (bounded by its declared size and the buffer). let mut best: Option<(usize, usize)> = None; // (data offset, usable payload len) let mut i = 0; while let Some(di) = find(slb, b"data", i) { let declared = le32(slb, di + 4).unwrap_or(0) as usize; let usable = declared.min(slb.len().saturating_sub(di + 8)); if best.map_or(true, |(_, b)| usable > b) { best = Some((di, usable)); } i = di + 4; } if let (Some(fi), Some((di, sz))) = (find(slb, b"fmt ", 0), best) { if sz > 512 { let fsz = le32(slb, fi + 4)? as usize; let fmt_end = (fi + 8 + fsz).min(slb.len()); let data = slb.get(di + 8..di + 8 + sz)?; return Some(build_riff(slb.get(fi..fmt_end)?, data)); } } // Headerless fallback: fixed data offset, synthesized XMA1 stereo/48k fmt. let data = slb.get(HEADERLESS_DATA_OFFSET..)?; (!data.is_empty()).then(|| 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 list_voice_clips_covers_movie_radio_and_briefing() { // The standalone player must enumerate every spoken-line category: bound // movie voices, in-mission radio (\etc\ + \Voice\), and briefing (\Briefing\, // whose BR_ names lack "VOICE"). Music (BGM_*) must stay excluded. let mut tbl = Vec::new(); for s in [ "eng\\Movie\\VOICE_S13A.slb", "eng\\etc\\VOICE_D_450.slb", "eng\\Voice\\VOICE_ADAN_010.slb", "eng\\Briefing\\BR01_01.slb", "eng\\bgm\\BGM_001.slb", ] { tbl.extend_from_slice(s.as_bytes()); tbl.push(0); } let names: Vec = list_voice_clips(&tbl, VoiceLang::English) .into_iter() .map(|c| c.name) .collect(); for want in [ "eng\\Movie\\VOICE_S13A.slb", "eng\\etc\\VOICE_D_450.slb", "eng\\Voice\\VOICE_ADAN_010.slb", "eng\\Briefing\\BR01_01.slb", ] { assert!(names.iter().any(|n| n == want), "missing {want}"); } assert!( !names.iter().any(|n| n.contains("BGM_")), "music must not be listed as a voice clip" ); } #[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)); } }