Cracked how the game maps a movie to its voice track and reimplemented it,
fixing wrong/short voices for RT and hokyu cutscenes.
## Mechanism (RE'd from a Canary file-I/O trace, user-confirmed by ear)
The movie voices are ONE continuous XMA stream, physically chunked into
`<lang>\Movie\VOICE_*.slb` (and `\etc\VOICE_D_*.slb`) sound.pak TOC entries
whose boundaries do NOT align with the cutscene cues. A single cue routinely
spans two `.slb` chunks, so a `.slb` does not necessarily hold the track its
name claims (they are off-by-one vs the cues). Each cue ends at an inline
trailer descriptor `(sound_id:u32be, 0x11, …)` whose id repeats at +0x800.
Resolution chain (all derivable on-disc, no guest-code RE):
1. movie -> cue token (ADVERTISE_MOVIE manifest VOICETRACK)
2. cue token -> sound-id (master sound registry, tables.pak
a2c8c185=eng / b04238e0=jpn, schema 0x13cb84ba)
3. sound-id -> [start,end) scan the stream for trailer(id) and its
predecessor; cue audio = the bytes between,
read continuously across chunk boundaries.
Validated: decoded voice length matches each movie within ~0.4s, including
cross-boundary cues (ADV/RT01A/RT01B/RT01C/S00A/S01A + the 5 bound hokyu).
## Changes
- sylpheed-formats/src/movie_voice.rs (new): registry_voice_ids(),
find_descriptor(), find_descriptor_before() (gap-tolerant predecessor).
Unit-tested.
- viewer iso_loader.rs: resolve_movie_voice_region() + decode_voice_region()
(continuous read via existing read_segment_range + to_xma_riffs). Voice/
audio requests now region-first; a `\Movie\` token that fails region stays
SILENT (never plays the wrong off-by-one chunk). Removed the old
movie_is_demo_based suppress hack.
- Anchor tries subdirs Movie/etc/Voice so hokyu `\etc\VOICE_D_*` resolve too.
- Examples resolve_all/validate_cues/hokyu_final document + validate the pipeline.
## Status
- RT / S / ADV cutscene voices: CORRECT (user-confirmed).
- 5 manifest-bound hokyu (VOICE_D_450-454): CORRECT (user-confirmed).
## OPEN (handoff)
13 of 18 hokyu movies have NO manifest VOICETRACK; the game reuses the 5
recordings across stages via a code rule. `hokyu_fallback_token()` currently
guesses by category (ship LS/DS x source carrier-A/tanker-H: LS/A->450,
LS/H->453, DS/A->452, DS/H->454). USER REPORTS THIS IS SOMETIMES WRONG.
- LS/carrier has two takes (450 from s02A, 451 from s09A); the early-vs-late
split is unknown — unbound LS/A currently all default to 450.
- Definitive fix = Canary `--phase_a_fileio_only` file.read trace of an unbound
hokyu (e.g. hokyu_LS_s03A) to see which VOICE_D the game actually streams,
then encode the real rule. Same method that cracked the RT mapping.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
371 lines
15 KiB
Rust
371 lines
15 KiB
Rust
//! `.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;
|
||
|
||
/// 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 `<lang>\{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<VoiceClip> {
|
||
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<NN>_<MM>.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 {
|
||
// `<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*`.)
|
||
pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
|
||
let mut out = Vec::new();
|
||
if find(slb, b"RIFF", 0).is_none() {
|
||
// Headerless single-stream bank.
|
||
if let Some(data) = slb.get(HEADERLESS_DATA_OFFSET..) {
|
||
if !data.is_empty() {
|
||
out.push(build_riff(&synth_xma1_fmt(2, 2, 48000), data));
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
let mut pos = 0usize;
|
||
while let Some(ri) = find(slb, b"RIFF", pos) {
|
||
// Parse this sub-wave's fmt + data (declared size is honest per sub-wave).
|
||
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
|
||
// 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))
|
||
}
|
||
}
|
||
|
||
/// 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 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));
|
||
}
|
||
}
|