This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/crates/sylpheed-formats/src/slb.rs
Sylpheed RE agent 306a8a5661 viewer: open the whole sound bank, not just the voice half
The library enumerator kept only names containing VOICE or \Briefing\, and read
eng\sounds.tbl unconditionally. So the Explorer could reach 4382 of the 9519
banks in sound.pak: no music, no jingles, no sound effects, and no Japanese
voice at all -- roughly half the disc's audio had no route to the UI.

`slb::list_audio_entries` now returns every named bank with the category its
path implies (Music / Jingles / Sound effects / Radio / Dialogue / Movie voice /
Briefing). `list_voice_clips` is that, restricted to the spoken categories, so
its existing test still guards the old behaviour. The 36 root banks carry no
language component and appear whichever table is read; the window gets an
English/Japanese switch that re-reads the other sounds.tbl, since the table name
IS the selector.

Two defects the decode found, both recorded in
docs/re/structures/sound-pak-contents.md:

* `Static.slb` -- the SFX bank -- declares 616768 bytes more than sound.p04
  holds. Not our extraction: p04 matches the ISO's own directory record, and a
  sweep of every pak on the disc finds this one entry over-running and no other.
  It is the highest-offset entry, so its comp_size is an allocation size. A
  short read is now allowed for the tail entry ONLY; any other overrun stays an
  error, because clamping it would hide real damage behind a half-decoded asset.
  The bank went from unreadable to 514 s of audio.

* the left-channel downmix was applied to everything. Right for voice (mono
  content however stored), wrong for music (a real stereo mix, half of it
  discarded). The caller now decides from the category.

35 of the 36 shared banks decode; JNGL_001 does not, and says so in the player
instead of the panel silently closing. Its payload is not a whole number of XMA1
packets from any known data offset, so it is likely not a plain headerless
stream -- written up rather than papered over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:28:20 +02:00

724 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `.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 `<lang>\Voice\…`, `<lang>\Movie\VOICE_<movie>.slb`,
//! `BGM_###.slb`, etc. (see `docs/re/structures/sound-slb.md`); look them up by
//! [`crate::hash::name_hash`]. Movie cutscene voice = one continuous
//! `<eng|jpn>\Movie\VOICE_<movie>.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, Default)]
pub enum VoiceLang {
/// The default only because the disc's own default audio track is English;
/// nothing else about the code should assume it.
#[default]
English,
Japanese,
}
impl VoiceLang {
pub const ALL: [VoiceLang; 2] = [VoiceLang::English, VoiceLang::Japanese];
pub fn label(self) -> &'static str {
match self {
VoiceLang::English => "English",
VoiceLang::Japanese => "Japanese",
}
}
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,
}
/// What kind of audio a `sounds.tbl` entry names.
///
/// The split is the on-disc path shape, not a guess: the 36 language-independent
/// banks sit at the table root (`BGM_###.slb`, `JNGL_00#.slb`, `Static.slb`),
/// while everything else is under `<lang>\<dir>\`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum AudioCategory {
/// `BGM_###.slb` — 32 music tracks, language-independent.
Music,
/// `JNGL_00#.slb` — 3 short jingles (mission clear / fail stings).
Jingle,
/// `Static.slb` — the sound-effect bank, one 9 MB multi-wave bank.
Sfx,
/// `<lang>\Voice\` — in-mission radio chatter, by speaker.
Radio,
/// `<lang>\etc\` — the other spoken lines (cutscene dialogue, system).
Dialogue,
/// `<lang>\Movie\VOICE_<movie>.slb` — a cutscene's continuous voice track.
MovieVoice,
/// `<lang>\Briefing\BR<NN>_<MM>.slb` — mission briefing lines.
Briefing,
/// A `.slb` whose path matched no known shape.
Other,
}
impl AudioCategory {
pub const ALL: [AudioCategory; 8] = [
AudioCategory::Music,
AudioCategory::Jingle,
AudioCategory::Sfx,
AudioCategory::Radio,
AudioCategory::Dialogue,
AudioCategory::MovieVoice,
AudioCategory::Briefing,
AudioCategory::Other,
];
pub fn label(self) -> &'static str {
match self {
AudioCategory::Music => "Music",
AudioCategory::Jingle => "Jingles",
AudioCategory::Sfx => "Sound effects",
AudioCategory::Radio => "Radio",
AudioCategory::Dialogue => "Dialogue",
AudioCategory::MovieVoice => "Movie voice",
AudioCategory::Briefing => "Briefing",
AudioCategory::Other => "Other",
}
}
/// True for the categories that are spoken lines — the set
/// [`list_voice_clips`] returns.
pub fn is_voice(self) -> bool {
matches!(
self,
AudioCategory::Radio
| AudioCategory::Dialogue
| AudioCategory::MovieVoice
| AudioCategory::Briefing
)
}
/// True when the bank is language-independent, so it appears whichever
/// `<lang>\sounds.tbl` is read.
pub fn is_shared(self) -> bool {
matches!(
self,
AudioCategory::Music | AudioCategory::Jingle | AudioCategory::Sfx
)
}
fn classify(name: &str) -> AudioCategory {
let leaf = name.rsplit('\\').next().unwrap_or(name);
if !name.contains('\\') {
return if leaf.starts_with("BGM_") {
AudioCategory::Music
} else if leaf.starts_with("JNGL_") {
AudioCategory::Jingle
} else if leaf.eq_ignore_ascii_case("Static.slb") {
AudioCategory::Sfx
} else {
AudioCategory::Other
};
}
match name.rsplit('\\').nth(1) {
Some("Voice") => AudioCategory::Radio,
Some("etc") => AudioCategory::Dialogue,
Some("Movie") => AudioCategory::MovieVoice,
Some("Briefing") => AudioCategory::Briefing,
_ => AudioCategory::Other,
}
}
}
/// One playable bank named in `sounds.tbl`, with the category its path implies.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AudioEntry {
pub clip: VoiceClip,
pub category: AudioCategory,
}
/// Enumerate **every** `.slb` bank named in a decompressed `sounds.tbl` (the
/// IDXD in `tables.pak`): the language-independent music/jingle/SFX banks at
/// the table root, plus every `<lang>\…` spoken line.
///
/// Measured on the retail disc: `eng\sounds.tbl` names 4 418 banks (36 shared +
/// 2 382 Radio + 1 821 Dialogue + 101 Briefing + 78 Movie voice) and
/// `jpn\sounds.tbl` names 5 136 (the same 36 shared + 5 100 Japanese lines).
/// Every one of the 36 shared names resolves to a `sound.pak` TOC entry under
/// [`crate::hash::name_hash`], which is the check that they are real banks and
/// not stale table text.
pub fn list_audio_entries(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<AudioEntry> {
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 name a `.slb`.
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]) {
// Take this language's entries plus the root (shared) banks; a
// path under the OTHER language would be a table artefact.
let mine = s.starts_with(&prefix) || !s.contains('\\');
if mine && s.ends_with(".slb") && seen.insert(s.to_string()) {
out.push(AudioEntry {
category: AudioCategory::classify(s),
clip: parse_voice_clip(s),
});
}
}
}
i += 1;
}
out
}
/// Enumerate just the spoken-line clips — [`list_audio_entries`] restricted to
/// [`AudioCategory::is_voice`].
///
/// In-mission radio (`\Voice\`, `\etc\`) and bound movie voices (`\Movie\`) all
/// carry `VOICE_`; mission-briefing lines live in `\Briefing\` as
/// `BR<NN>_<MM>.slb` and carry no `VOICE` at all, which is why the category —
/// i.e. the directory — decides this and not the filename.
pub fn list_voice_clips(sounds_tbl: &[u8], lang: VoiceLang) -> Vec<VoiceClip> {
list_audio_entries(sounds_tbl, lang)
.into_iter()
.filter(|e| e.category.is_voice())
.map(|e| e.clip)
.collect()
}
fn parse_voice_clip(name: &str) -> VoiceClip {
// `<lang>\<cat>\VOICE_<SPK>_<NNN>.slb` or `..\VOICE_<movie>.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<u8> {
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<usize> {
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 `<lang>\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<Vec<u8>> {
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 `<lang>\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<Vec<u8>> {
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 S10S16
// 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 `<lang>\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<Vec<u8>> {
// 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<u8> {
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<u8> {
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<usize> {
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<u32> {
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<NN>_<MM> 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<String> = 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 list_audio_entries_categorises_root_banks_and_keeps_them_language_shared() {
// The three root banks carry no language component, so BOTH sounds.tbl
// files name them; a language filter that only accepted `<lang>\` would
// silently drop all the music, which is what it used to do.
let mut tbl = Vec::new();
for s in [
"BGM_001.slb",
"JNGL_002.slb",
"Static.slb",
"eng\\Voice\\VOICE_ADAN_010.slb",
"eng\\etc\\VOICE_D_450.slb",
"eng\\Movie\\VOICE_S13A.slb",
"eng\\Briefing\\BR01_01.slb",
] {
tbl.extend_from_slice(s.as_bytes());
tbl.push(0);
}
let by = |lang| {
list_audio_entries(&tbl, lang)
.into_iter()
.map(|e| (e.clip.name, e.category))
.collect::<Vec<_>>()
};
let eng = by(VoiceLang::English);
let want = [
("BGM_001.slb", AudioCategory::Music),
("JNGL_002.slb", AudioCategory::Jingle),
("Static.slb", AudioCategory::Sfx),
("eng\\Voice\\VOICE_ADAN_010.slb", AudioCategory::Radio),
("eng\\etc\\VOICE_D_450.slb", AudioCategory::Dialogue),
("eng\\Movie\\VOICE_S13A.slb", AudioCategory::MovieVoice),
("eng\\Briefing\\BR01_01.slb", AudioCategory::Briefing),
];
assert_eq!(eng.len(), want.len());
for (n, c) in want {
assert!(
eng.iter().any(|(en, ec)| en == n && *ec == c),
"{n} not categorised as {c:?}"
);
}
// Reading the Japanese table yields the shared banks and none of the
// English lines.
let jpn = by(VoiceLang::Japanese);
assert_eq!(jpn.len(), 3, "only the shared banks: {jpn:?}");
assert!(jpn.iter().all(|(_, c)| c.is_shared()));
// And the voice view is exactly the non-shared half.
assert_eq!(list_voice_clips(&tbl, VoiceLang::English).len(), 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));
}
}