//! The movie manifest: the game's authoritative **mission → phase → cutscene** //! table (statically reversed). Beyond the movie→voice binding, it defines the //! whole cutscene *structure*: which video (+ subtitle, + on-screen text overlay, //! + voice) plays at each mission phase, and of what kind (story intro, in-mission //! phase cutscene, resupply, …). //! //! ## On-disc structure (`dat/tables.pak`, entry hash `0x5b983a08`) //! //! One `IDXD` table (`Z1`+zlib block), schema `0x067025b9`. Unlike a flat IDXD //! object, this one is an **array of records**, and its string pool is laid out //! as **two consecutive arrays**: //! 1. the **slot KEYS** — `LOGO1`, `ADVERTISE_MOVIE`, `STAFF_ROLL`, then one //! per cutscene: `MS`, `STAGE_PHASE0`, `STAGE_PHASE_END…`, //! `S_SUPPLY_`, in play order; //! 2. the **value RECORDS**, in the same order — each a run led by `.wmv` //! and (optionally) a `+…​.prt` overlay (field `TELOP`), a //! `+SUBTITLE_.tbl` (field `SUBTITLE`), and a `VOICE_` //! (field `VOICETRACK`). A few slot keys are **valueless** (e.g. stages 5 and //! 12 have no resupply video) — those slots have no record and are skipped. //! //! The **slot key is the semantic role** — this is how the game separates a //! cutscene voice from in-mission radio chatter (structurally, by which slot/bank, //! not one boolean): `MS*` = story intro (`VOICE_S*`), `STAGE*_PHASE*` = radio-box //! cutscene (`VOICE_RT*`), `S*_SUPPLY_*` = resupply (`VOICE_D_45x` or none). //! Resupply videos are **reused across stages** (`S04_SUPPLY_ACROPOLIS` → //! `hokyu_DS_s02A`), so the mapping is genuinely a table, not derivable from names. //! //! The voice token is likewise **not** always `VOICE_`: most movies use //! `VOICE_` (in `\Movie\`), but the resupply cutscenes bind to //! in-mission radio clips like `VOICE_D_450` (in `\etc\`), and some have no //! voice at all. This manifest is the ground truth. //! //! ## Alignment //! //! Slot keys and value records would be a 1:1 positional zip but for the valueless //! slots. We recover the gaps without decoding the binary node/index region by //! using the fully-derivable **`MS` → `S` anchors**: a non-`MS` slot //! whose record would be an `MS` target actually belongs to the upcoming `MS` //! slot, so the non-`MS` slot is a gap. (Decoding the node region would make this //! exact and also unlock the defaulted numeric fields in other IDXD tables.) use crate::slb::VoiceLang; /// The role a cutscene slot plays in the mission flow, from its slot key. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MovieKind { /// `LOGO*`, `ADVERTISE_MOVIE`, `STAFF_ROLL` — boot/system movies. System, /// `MS` — story intro movie (camera on the speaker; stereo `VOICE_S*`). Intro, /// `STAGE_PHASE0` — in-mission phase cutscene (radio box; `VOICE_RT*`). Phase, /// `STAGE_PHASE_END[_0N]` — phase-end cutscene. PhaseEnd, /// `S_SUPPLY_` — resupply (hokyu) cutscene. Supply, } /// One cutscene slot's manifest row: its semantic slot plus the bound assets. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MovieEntry { /// The slot key, e.g. `STAGE01_PHASE01`, `MS00A`, `S02_SUPPLY_ACROPOLIS`. /// Empty when parsed from a blob without the slot-key array (fallback). pub slot: String, /// The slot's role in the mission flow (intro vs. phase cutscene vs. supply …). pub kind: MovieKind, /// Mission/stage number (`STAGE` / `MS` / `S_SUPPLY`), if any. pub mission: Option, /// Phase number for [`MovieKind::Phase`]/[`MovieKind::PhaseEnd`], if any. pub phase: Option, /// Movie basename without extension, e.g. `S13A`, `RT01A`, `hokyu_DS_s13A`. pub movie: String, /// Voice bank basename (`VOICE_S13A`, `VOICE_D_450`), or `None` for no voice. pub voice_token: Option, /// Subtitle table basename (`SUBTITLE_S13A.tbl`), pak prefix stripped, if any. pub subtitle: Option, /// On-screen text-overlay (`TELOP`) basename (`pwrt01.prt`), if any. pub telop: Option, } /// 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_") } /// The field-type keys that appear once in the value region as a schema template. const FIELD_KEYS: [&str; 4] = ["MOVIE", "VOICETRACK", "SUBTITLE", "TELOP"]; /// Parse the manifest into per-slot cutscene rows, in play order. /// /// Each returned [`MovieEntry`] carries a movie; valueless slots (e.g. stage 5's /// absent resupply) are omitted. When the two-array slot/record layout is present /// (the real manifest) every row is tagged with its authoritative slot/kind/ /// mission/phase; otherwise (a blob without the key array) the kind is inferred /// from the movie name and `slot` is empty. pub fn parse(bytes: &[u8]) -> Vec { let toks = ascii_runs(bytes, 3); // Locate the slot-key array (`LOGO1` … first `.wmv`) and the value // region (from the first `.wmv` onward). Fall back to a keyless scrape. let first_wmv = toks.iter().position(|t| t.ends_with(".wmv")); let key_start = toks.iter().position(|t| t == "LOGO1"); let (slot_keys, value_toks): (Vec<&str>, &[String]) = match (key_start, first_wmv) { (Some(k), Some(w)) if k < w => ( toks[k..w].iter().map(String::as_str).collect(), &toks[w..], ), _ => (Vec::new(), toks.as_slice()), }; // Build the value records (drop the schema-template field keys). let mut records: Vec = Vec::new(); for t in value_toks.iter().filter(|t| !FIELD_KEYS.contains(&t.as_str())) { if let Some(name) = t.strip_suffix(".wmv") { records.push(MovieEntry { slot: String::new(), kind: kind_from_movie(name), mission: None, phase: None, movie: name.to_string(), voice_token: None, subtitle: None, telop: None, }); } else if let Some(rec) = records.last_mut() { if let Some(v) = t.strip_prefix("VOICE_") { if rec.voice_token.is_none() { rec.voice_token = Some(format!("VOICE_{v}")); } } else if t.ends_with(".tbl") { rec.subtitle.get_or_insert_with(|| after_plus(t)); } else if t.ends_with(".prt") { rec.telop.get_or_insert_with(|| after_plus(t)); } } } // Attach slot keys to records, recovering valueless-slot gaps via MS anchors. if !slot_keys.is_empty() { let ms_targets: std::collections::HashSet = slot_keys .iter() .filter_map(|k| ms_target(k)) .collect(); let mut j = 0usize; for slot in &slot_keys { if j >= records.len() { break; } // A gap: this slot's "record" is actually the next MS slot's movie. let would_be = &records[j].movie; let is_gap = match ms_target(slot) { // MS slot: only binds if its record is the expected `S`. Some(expected) => *would_be != expected, // Non-MS slot: a gap iff the record belongs to an upcoming MS slot. None => ms_targets.contains(would_be), }; if is_gap { continue; } let (kind, mission, phase) = classify_slot(slot); let rec = &mut records[j]; rec.slot = (*slot).to_string(); rec.kind = kind; rec.mission = mission; rec.phase = phase; j += 1; } } records } /// If `slot` is an `MS` intro slot, the movie it must bind to (`S`). fn ms_target(slot: &str) -> Option { let rest = slot.strip_prefix("MS")?; // ``: two digits then a letter suffix. if rest.len() >= 3 && rest[..2].bytes().all(|b| b.is_ascii_digit()) { Some(format!("S{rest}")) } else { None } } /// Semantic role + mission/phase from a slot key. fn classify_slot(slot: &str) -> (MovieKind, Option, Option) { if let Some(rest) = slot.strip_prefix("MS") { if rest.len() >= 2 { return (MovieKind::Intro, rest[..2].parse().ok(), None); } } if let Some(rest) = slot.strip_prefix("STAGE") { let nn: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); if let Ok(m) = nn.parse::() { if slot.contains("PHASE_END") { // trailing `_NN` if present (`STAGE12_PHASE_END_02`) let phase = slot.rsplit('_').next().and_then(|x| x.parse().ok()); return (MovieKind::PhaseEnd, Some(m), phase); } // `STAGE_PHASE0` let phase = rest[nn.len()..] .strip_prefix("_PHASE") .and_then(|p| p.parse().ok()); return (MovieKind::Phase, Some(m), phase); } } if slot.contains("_SUPPLY") { if let Some(rest) = slot.strip_prefix('S') { let nn: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); return (MovieKind::Supply, nn.parse().ok(), None); } } (MovieKind::System, None, None) } /// Fallback kind from a movie basename (used when no slot-key array is present). fn kind_from_movie(movie: &str) -> MovieKind { if movie.starts_with("hokyu_") { MovieKind::Supply } else if movie.starts_with("RT") { MovieKind::Phase } else if movie.len() >= 3 && movie.starts_with('S') && movie[1..3].bytes().all(|b| b.is_ascii_digit()) { MovieKind::Intro } else { MovieKind::System } } /// The part of a `+` reference after the `+` (or the whole string). fn after_plus(s: &str) -> String { s.rsplit('+').next().unwrap_or(s).to_string() } /// 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 { 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 { let token = voice_token(manifest, movie)?; resolve_token_path(sounds_tbl, &token, lang) } /// Find the full `\…\.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 { 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 { 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 { // 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() { // Keyless fallback path: movie + voice + subtitle + inferred kind. 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[0].kind, MovieKind::Intro); assert_eq!(m[0].subtitle.as_deref(), Some("SUBTITLE_S13A.tbl")); assert_eq!(m[1].voice_token.as_deref(), Some("VOICE_D_450")); assert_eq!(m[1].kind, MovieKind::Supply); assert_eq!(m[2].voice_token, None, "hokyu_DS_s13A has no voice-over"); assert_eq!(m[2].kind, MovieKind::Supply); } /// Build a two-array (keys-then-records) manifest like the real one. fn synth_two_array(keys: &[&str], records: &[&[&str]]) -> Vec { let mut b = b"IDXD".to_vec(); b.extend_from_slice(&[0, 0, 0, 0x05, 0, 0]); for k in keys { b.extend_from_slice(k.as_bytes()); b.push(0); } for rec in records { for s in *rec { b.extend_from_slice(s.as_bytes()); b.push(0); } } b } #[test] fn two_array_slots_kind_mission_phase() { let blob = synth_two_array( &["LOGO1", "MS01A", "STAGE01_PHASE01", "STAGE01_PHASE_END_02"], &[ &["logo1.wmv", "MOVIE"], &["S01A.wmv", "eng.pak+SUBTITLE_S01A.tbl", "VOICE_S01A"], &["RT01A.wmv", "eng.pak+pwrt01.prt", "VOICE_RT01A", "VOICETRACK"], &["RT01C_2.wmv", "VOICE_RT01C_2"], ], ); let m = parse(&blob); assert_eq!(m.len(), 4); assert_eq!((m[0].slot.as_str(), m[0].kind), ("LOGO1", MovieKind::System)); assert_eq!( (m[1].slot.as_str(), m[1].kind, m[1].mission), ("MS01A", MovieKind::Intro, Some(1)) ); assert_eq!( (m[2].slot.as_str(), m[2].kind, m[2].mission, m[2].phase), ("STAGE01_PHASE01", MovieKind::Phase, Some(1), Some(1)) ); assert_eq!(m[2].telop.as_deref(), Some("pwrt01.prt")); assert_eq!( (m[3].slot.as_str(), m[3].kind, m[3].phase), ("STAGE01_PHASE_END_02", MovieKind::PhaseEnd, Some(2)) ); } #[test] fn valueless_supply_slot_is_skipped_via_ms_anchor() { // S05_SUPPLY has no record; its "record" (S06A) belongs to the next MS slot. let blob = synth_two_array( &["LOGO1", "MS05A", "S05_SUPPLY_ACROPOLIS", "MS06A"], &[ &["logo1.wmv"], &["S05A.wmv", "VOICE_S05A"], &["S06A.wmv", "VOICE_S06A"], ], ); let m = parse(&blob); assert_eq!(m.len(), 3, "the valueless supply slot yields no entry"); assert_eq!((m[1].slot.as_str(), m[1].movie.as_str()), ("MS05A", "S05A")); // S06A binds to MS06A, NOT to the intervening valueless supply slot. assert_eq!((m[2].slot.as_str(), m[2].movie.as_str()), ("MS06A", "S06A")); assert!(m.iter().all(|e| e.slot != "S05_SUPPLY_ACROPOLIS")); } #[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 ); } }