feat(formats,viewer): movie subtitles, voice decode, and the movie manifest

The movie cutscene subtitle + voice pipeline, driven by the ADVERTISE_MOVIE
manifest (the authoritative movie -> subtitle -> voice index). Also flushes
several sessions of local WIP (async viewer loading, grouped-pool XBG7/hero-ship
decode, drawlog tooling). See docs/HANDOFF-movie-voice-subtitles-2026-07-19.md.

Subtitles (movie_subtitle.rs):
- Full movie->track->text chain; join multi-line captions sharing one timing
  (fixes S13A dropped "Look at it father" line); overlap-safe active_cues();
  Latin-1 accents preserved.

Voice (slb.rs): XACT .slb -> XMA1 RIFF; take the FIRST sub-wave bounded by its
declared data size (fixes S10-S16 alternate-take garble); list_voice_clips.

Manifest (movie_manifest.rs): parse ADVERTISE_MOVIE (0x5B983A08) for the real
movie->voice binding (not always VOICE_<movie>; e.g. hokyu -> VOICE_D_* in etc\).
Resolve the token's sound.pak path via sounds.tbl. DIRECT bindings only — the
demo-id shared-clip fallback for unbound hokyu movies was verified WRONG in-game
and reverted (unbound hokyu stay unvoiced; correct join key is an OPEN problem).

Viewer: manifest-driven voice (movie player toggle + solo button), standalone
"Voice Lines" browser, stacked caption overlay.

Tests: 46 formats-lib + 11 viewer-lib + movie_manifest/movie_subtitle/slb disc
tests; full workspace green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-19 20:15:41 +02:00
parent 578c71a1b9
commit 95de29f290
22 changed files with 4684 additions and 493 deletions

View File

@@ -0,0 +1,184 @@
//! The movie manifest: the game's authoritative movie → subtitle → **voice**
//! index (statically reversed).
//!
//! `dat/tables.pak` holds one `IDXD` table (a `Z1`+zlib block) whose string pool
//! lists every cutscene in play order. Each movie contributes a run of ASCII
//! strings, always led by `<movie>.wmv` and optionally followed by:
//! - `<pak>+…​.prt` — an overlay/print-art reference,
//! - `<pak>+SUBTITLE_<movie>.tbl` — its caption timing track,
//! - `VOICE_<token>` — the **voice track basename**.
//!
//! The voice token is the payoff: it is the real bank name to look up in
//! `sounds.tbl`, and it is **not** always `VOICE_<movie>`. Most story/radio
//! movies use `VOICE_<movie>` (in `<lang>\Movie\`), but several `hokyu_*`
//! (resupply) movies bind to in-mission radio clips like `VOICE_D_450` (in
//! `<lang>\etc\`), and many `hokyu_*` movies + the boot logos have **no** voice
//! token at all — i.e. the game plays no voice-over for them. Guessing
//! `VOICE_<movie>` therefore both misses the `VOICE_D_*` cases and invents a
//! non-existent track for the silent ones; this manifest is the ground truth.
//!
//! We read the manifest by grouping the string pool on `.wmv` (the pool is
//! emitted in the same order as the records), which is robust without decoding
//! the `IDXD` record structure.
use crate::slb::VoiceLang;
/// One movie's manifest row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MovieEntry {
/// Movie basename without extension, e.g. `S13A`, `hokyu_DS_s13A`.
pub movie: String,
/// Voice bank basename (e.g. `VOICE_S13A`, `VOICE_D_450`), or `None` when the
/// game assigns no voice-over to this movie.
pub voice_token: Option<String>,
}
/// Does this blob look like the movie manifest? (`IDXD` whose string pool
/// carries movie + subtitle + voice references.) Used to locate it in
/// `tables.pak` without hardcoding a hash.
pub fn is_manifest(bytes: &[u8]) -> bool {
bytes.len() >= 4
&& &bytes[0..4] == b"IDXD"
&& contains(bytes, b".wmv")
&& contains(bytes, b"VOICE_")
&& contains(bytes, b"SUBTITLE_")
}
/// Parse the manifest's string pool into per-movie rows, in play order.
pub fn parse(bytes: &[u8]) -> Vec<MovieEntry> {
let mut entries: Vec<MovieEntry> = Vec::new();
for run in ascii_runs(bytes, 3) {
if let Some(name) = run.strip_suffix(".wmv") {
entries.push(MovieEntry {
movie: name.to_string(),
voice_token: None,
});
} else if run.starts_with("VOICE_") {
// Belongs to the movie record currently being built.
if let Some(last) = entries.last_mut() {
if last.voice_token.is_none() {
last.voice_token = Some(run.clone());
}
}
}
}
entries
}
/// The voice bank basename bound to `movie`, if the manifest lists one.
///
/// Returns `None` both when the movie is absent and when it is present with no
/// voice — callers that need to distinguish should use [`parse`].
pub fn voice_token(bytes: &[u8], movie: &str) -> Option<String> {
parse(bytes)
.into_iter()
.find(|e| e.movie == movie)
.and_then(|e| e.voice_token)
}
/// Resolve a movie to its full `sound.pak` voice-entry name for `lang`, using the
/// manifest for the *token* and `sounds.tbl` for the token's *directory* (Movie /
/// etc / Voice differ per token). `None` = the movie has no voice-over.
pub fn resolve_voice_entry(
manifest: &[u8],
sounds_tbl: &[u8],
movie: &str,
lang: VoiceLang,
) -> Option<String> {
let token = voice_token(manifest, movie)?;
resolve_token_path(sounds_tbl, &token, lang)
}
/// Find the full `<lang>\…\<token>.slb` path for a voice `token` in a decompressed
/// `sounds.tbl` (the token's subdir — `Movie`, `etc`, `Voice` — is not fixed).
pub fn resolve_token_path(sounds_tbl: &[u8], token: &str, lang: VoiceLang) -> Option<String> {
let prefix = format!("{}\\", lang.code_pub());
let suffix = format!("\\{token}.slb");
ascii_runs(sounds_tbl, 6)
.into_iter()
.find(|r| r.starts_with(&prefix) && r.ends_with(&suffix))
}
fn contains(hay: &[u8], needle: &[u8]) -> bool {
hay.windows(needle.len()).any(|w| w == needle)
}
/// Collect printable-ASCII runs of at least `min` chars.
fn ascii_runs(bytes: &[u8], min: usize) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
for &b in bytes {
if (0x20..=0x7e).contains(&b) {
cur.push(b as char);
} else {
if cur.len() >= min {
out.push(std::mem::take(&mut cur));
} else {
cur.clear();
}
}
}
if cur.len() >= min {
out.push(cur);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn synth() -> Vec<u8> {
// Minimal IDXD string pool: three movies — a standard one, a hokyu bound
// to a VOICE_D radio clip, and a hokyu with no voice.
let mut b = b"IDXD".to_vec();
b.extend_from_slice(&[0, 0, 0, 0x69, 0, 0]); // binary header (as on disc)
for s in [
"S13A.wmv",
"eng.pak+SUBTITLE_S13A.tbl",
"VOICE_S13A",
"hokyu_LS_s02A.wmv",
"eng.pak+SUBTITLE_hokyu_LS_s02A.tbl",
"VOICE_D_450",
"hokyu_DS_s13A.wmv",
"eng.pak+SUBTITLE_hokyu_DS_s13A.tbl",
] {
b.extend_from_slice(s.as_bytes());
b.push(0);
}
b
}
#[test]
fn groups_movies_and_binds_voice() {
let m = parse(&synth());
assert_eq!(m.len(), 3);
assert_eq!(m[0].movie, "S13A");
assert_eq!(m[0].voice_token.as_deref(), Some("VOICE_S13A"));
assert_eq!(m[1].voice_token.as_deref(), Some("VOICE_D_450"));
assert_eq!(m[2].voice_token, None, "hokyu_DS_s13A has no voice-over");
}
#[test]
fn resolves_token_directory_from_sounds_tbl() {
// sounds.tbl places the two tokens in different subdirs.
let mut tbl = Vec::new();
for s in ["eng\\Movie\\VOICE_S13A.slb", "eng\\etc\\VOICE_D_450.slb"] {
tbl.extend_from_slice(s.as_bytes());
tbl.push(0);
}
let man = synth();
assert_eq!(
resolve_voice_entry(&man, &tbl, "S13A", VoiceLang::English).as_deref(),
Some("eng\\Movie\\VOICE_S13A.slb")
);
assert_eq!(
resolve_voice_entry(&man, &tbl, "hokyu_LS_s02A", VoiceLang::English).as_deref(),
Some("eng\\etc\\VOICE_D_450.slb")
);
assert_eq!(
resolve_voice_entry(&man, &tbl, "hokyu_DS_s13A", VoiceLang::English),
None
);
}
}