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:
184
crates/sylpheed-formats/src/movie_manifest.rs
Normal file
184
crates/sylpheed-formats/src/movie_manifest.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user