re: rebuild the movie manifest on the record table — the old counts measured pool strings
movie_manifest::parse now reads BASE_INFO's positional field keys (the game's own cutscene ids, stage*100 + slot) and follows each to its record, instead of scraping the string pool. The pool stores each distinct string once, so a REPEAT reference produced no token and read as "no binding". That single cause explains every wrong cell: 13 later references to VOICE_D_450..454, two to SUBTITLE_hokyu_LS_s11A.tbl, and MS01A's share of pwterop_s01a.prt. All 18 hokyu movies are bound, not five. Counts, verified independently by me against the disc before recording: 104 cutscene SLOTS binding 101 distinct MOVIES; 99 slots / 96 movies with a voice track, 99 / 96 with a subtitle, 22 / 22 with a telop. The docs' old 94 / 83 / 21 are exactly the counts of DISTINCT POOL STRINGS -- not wrong measurements, measurements of the wrong thing. Three denominators were being conflated; the new test pins all three. Two assertions in movie_manifest_disc.rs were false and are corrected: hokyu_DS_s13A binds VOICE_D_452 and resolves to eng\etc\VOICE_D_452.slb. The in-game verdict that rejected that value tested an INFERENCE from a shared demo id, on a decoder that discards 85-87% of banks in this class -- see voice-bank-leading-region.md, committed earlier today. The ~104 script ids are no longer open: they are literal positional keys, each naming its record, and all 104 resolve. The old "counts differ by three, positional pairing does not work" has a concrete cause -- three resupply movies are bound by TWO slots each. Also corrected: the naming convention has 3 subtitle exceptions (s24A/s27A borrow s11A's track) and 18 voice exceptions, not one and five. The legacy scraper is kept as a fallback for blobs with no record table, so the synthetic unit fixtures still exercise it. Artifacts: examples/movie_map_csv.rs regenerates the CSV, now slot-keyed (104 rows; the movie-keyed version silently dropped one slot of each duplicate). Disc tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
This commit is contained in:
27
crates/sylpheed-formats/examples/movie_map_csv.rs
Normal file
27
crates/sylpheed-formats/examples/movie_map_csv.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! Regenerate `docs/re/captures/movie-subtitle-voice-map.csv` from the record
|
||||
//! table. Keyed by cutscene SLOT, not by movie: three resupply movies are bound
|
||||
//! by two slots each, and the old movie-keyed CSV silently dropped one of each.
|
||||
use sylpheed_formats::{movie_manifest, IdxdObject, PakArchive};
|
||||
|
||||
fn main() {
|
||||
let disc = std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC");
|
||||
let arc = PakArchive::open(format!("{disc}/dat/tables.pak")).expect("tables.pak");
|
||||
let manifest = arc
|
||||
.entries()
|
||||
.iter()
|
||||
.filter_map(|e| arc.read(e).ok())
|
||||
.find(|b| IdxdObject::is_idxd(b) && movie_manifest::is_manifest(b))
|
||||
.expect("movie manifest");
|
||||
|
||||
println!("slot,movie,telop,subtitle,voicetrack");
|
||||
for e in movie_manifest::parse(&manifest) {
|
||||
println!(
|
||||
"{},{}.wmv,{},{},{}",
|
||||
e.slot,
|
||||
e.movie,
|
||||
e.telop.unwrap_or_default(),
|
||||
e.subtitle.unwrap_or_default(),
|
||||
e.voice_token.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,61 @@ const FIELD_KEYS: [&str; 4] = ["MOVIE", "VOICETRACK", "SUBTITLE", "TELOP"];
|
||||
/// 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<MovieEntry> {
|
||||
if let Some(rows) = parse_records(bytes) {
|
||||
return rows;
|
||||
}
|
||||
parse_string_pool(bytes)
|
||||
}
|
||||
|
||||
/// Read the manifest out of the IDXD **record table** — the authoritative path.
|
||||
///
|
||||
/// `BASE_INFO` carries 104 *positional* fields whose keys are the game's own
|
||||
/// cutscene ids (`stage*100 + slot`) and whose values name sibling records; each
|
||||
/// of those records carries `MOVIE` / `VOICETRACK` / `SUBTITLE` / `TELOP`.
|
||||
///
|
||||
/// This replaces a string-pool scrape that could not see a **repeat reference**:
|
||||
/// the pool stores each distinct string once, so the 13 later references to
|
||||
/// `VOICE_D_450..454`, the two to `SUBTITLE_hokyu_LS_s11A.tbl` and `MS01A`'s
|
||||
/// share of `pwterop_s01a.prt` produced no token and read as "no binding". That
|
||||
/// is why `hokyu_DS_s13A` was recorded as having no voice-over.
|
||||
///
|
||||
/// Returns `None` for a blob with no usable record table, so the synthetic
|
||||
/// fixtures in this module's tests still exercise the old reader.
|
||||
fn parse_records(bytes: &[u8]) -> Option<Vec<MovieEntry>> {
|
||||
let obj = crate::IdxdObject::parse(bytes).ok()?;
|
||||
let base = obj.record("BASE_INFO")?;
|
||||
// Ids ascend; the game's own order is play order.
|
||||
let mut ids: Vec<(u32, &str)> = base
|
||||
.fields
|
||||
.iter()
|
||||
.filter_map(|f| f.index().map(|i| (i, f.value.as_str())))
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
ids.sort_unstable();
|
||||
let mut out = Vec::with_capacity(ids.len());
|
||||
for (_, slot) in ids {
|
||||
let Some(rec) = obj.record(slot) else { continue };
|
||||
let Some(movie) = rec.get("MOVIE") else { continue };
|
||||
let (kind, mission, phase) = classify_slot(slot);
|
||||
out.push(MovieEntry {
|
||||
slot: slot.to_string(),
|
||||
kind,
|
||||
mission,
|
||||
phase,
|
||||
movie: movie.trim_end_matches(".wmv").to_string(),
|
||||
voice_token: rec.get("VOICETRACK").map(after_plus),
|
||||
subtitle: rec.get("SUBTITLE").map(after_plus),
|
||||
telop: rec.get("TELOP").map(after_plus),
|
||||
});
|
||||
}
|
||||
(!out.is_empty()).then_some(out)
|
||||
}
|
||||
|
||||
/// The legacy string-pool reader. Kept for blobs with no record table; see
|
||||
/// [`parse_records`] for why it is no longer the primary path.
|
||||
fn parse_string_pool(bytes: &[u8]) -> Vec<MovieEntry> {
|
||||
let toks = ascii_runs(bytes, 3);
|
||||
|
||||
// Locate the slot-key array (`LOGO1` … first `<movie>.wmv`) and the value
|
||||
@@ -334,7 +389,11 @@ mod tests {
|
||||
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");
|
||||
// NB: in the synthetic blob below this movie really has no token. On the
|
||||
// retail disc it does — `VOICE_D_452` — which the string-pool reader
|
||||
// cannot see because the pool stores each value once and this is a
|
||||
// repeat reference. The disc test pins the real value.
|
||||
assert_eq!(m[2].voice_token, None);
|
||||
assert_eq!(m[2].kind, MovieKind::Supply);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,14 @@ fn binds_and_resolves_movie_voice() {
|
||||
};
|
||||
let (manifest, sounds) = load_manifest_and_sounds(&root);
|
||||
let entries = movie_manifest::parse(&manifest);
|
||||
assert!(entries.len() > 90, "expected ~101 movies, got {}", entries.len());
|
||||
// 104 cutscene SLOTS binding 101 distinct movies — three resupply movies are
|
||||
// bound by two slots each. Conflating the two is how the old counts went
|
||||
// wrong, so state which one this is.
|
||||
assert!(
|
||||
entries.len() > 90,
|
||||
"expected 101 distinct movies (104 slots), got {}",
|
||||
entries.len()
|
||||
);
|
||||
|
||||
// Standard story movie → VOICE_<movie> in <lang>\Movie\.
|
||||
assert_eq!(
|
||||
@@ -65,17 +72,26 @@ fn binds_and_resolves_movie_voice() {
|
||||
Some("eng\\etc\\VOICE_D_450.slb")
|
||||
);
|
||||
|
||||
// A resupply movie the game leaves unbound in the manifest → no DIRECT
|
||||
// binding. (Extending to unbound movies by shared demo line was verified
|
||||
// WRONG against the running game, so we do NOT resolve these.)
|
||||
// ❌ This movie was recorded as having NO voice binding, on the strength of
|
||||
// an in-game test that rejected `VOICE_D_452`. Both halves of that were
|
||||
// wrong. The record table binds it directly — record `S13_SUPPLY_ACROPOLIS`,
|
||||
// slot 1391 — and the value the game rejected was reached by *inferring*
|
||||
// from a shared demo id, which is different evidence for the same claim.
|
||||
// The decoder was also discarding 85–87 % of banks in this class, so the
|
||||
// listening test was not a test of the binding. See
|
||||
// docs/re/voice-bank-leading-region.md.
|
||||
//
|
||||
// The old reader could not see it: the pool stores each string once, so the
|
||||
// 13 later references to `VOICE_D_450..454` contribute no token at all.
|
||||
let e = entries
|
||||
.iter()
|
||||
.find(|e| e.movie == "hokyu_DS_s13A")
|
||||
.expect("hokyu_DS_s13A present in manifest");
|
||||
assert_eq!(e.voice_token, None, "hokyu_DS_s13A has no direct voice binding");
|
||||
assert_eq!(e.voice_token.as_deref(), Some("VOICE_D_452"));
|
||||
assert_eq!(
|
||||
movie_manifest::resolve_voice_entry(&manifest, &sounds, "hokyu_DS_s13A", VoiceLang::English),
|
||||
None
|
||||
movie_manifest::resolve_voice_entry(&manifest, &sounds, "hokyu_DS_s13A", VoiceLang::English)
|
||||
.as_deref(),
|
||||
Some("eng\\etc\\VOICE_D_452.slb")
|
||||
);
|
||||
|
||||
// Every resolved voice entry must actually exist in sound.pak.
|
||||
@@ -100,3 +116,54 @@ fn binds_and_resolves_movie_voice() {
|
||||
assert!(resolved > 80, "expected 80+ voiced movies, got {resolved}");
|
||||
assert!(missing.is_empty(), "resolved but absent in sound.pak: {missing:?}");
|
||||
}
|
||||
|
||||
/// The manifest's shape, pinned. These counts were wrong in the docs for a long
|
||||
/// time in a specific and instructive way: 94 / 83 / 21 are the numbers of
|
||||
/// **distinct pool strings**, which is exactly what a string-pool scraper can
|
||||
/// see, not the numbers of bound slots or movies.
|
||||
#[test]
|
||||
fn manifest_slot_and_movie_counts() {
|
||||
let Some(root) = disc_root() else {
|
||||
eprintln!("SKIP: set SYLPHEED_DISC");
|
||||
return;
|
||||
};
|
||||
let (manifest, _sounds) = load_manifest_and_sounds(&root);
|
||||
let entries = movie_manifest::parse(&manifest);
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
let movies: HashSet<&str> = entries.iter().map(|e| e.movie.as_str()).collect();
|
||||
assert_eq!(entries.len(), 104, "cutscene slots");
|
||||
assert_eq!(movies.len(), 101, "distinct movies");
|
||||
|
||||
let bound = |f: fn(&movie_manifest::MovieEntry) -> Option<&String>| {
|
||||
let slots = entries.iter().filter(|e| f(e).is_some()).count();
|
||||
let movies: HashSet<&str> = entries
|
||||
.iter()
|
||||
.filter(|e| f(e).is_some())
|
||||
.map(|e| e.movie.as_str())
|
||||
.collect();
|
||||
let distinct: HashSet<&str> = entries.iter().filter_map(|e| f(e)).map(String::as_str).collect();
|
||||
(slots, movies.len(), distinct.len())
|
||||
};
|
||||
// (slots, movies, distinct strings) — the third is what the docs used to report.
|
||||
assert_eq!(bound(|e| e.voice_token.as_ref()), (99, 96, 83));
|
||||
assert_eq!(bound(|e| e.subtitle.as_ref()), (99, 96, 94));
|
||||
assert_eq!(bound(|e| e.telop.as_ref()), (22, 22, 21));
|
||||
|
||||
// Three resupply movies are bound by two slots each — which is why "slots"
|
||||
// and "movies" are not interchangeable.
|
||||
let mut per_movie: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||
for e in &entries {
|
||||
per_movie.entry(&e.movie).or_default().push(&e.slot);
|
||||
}
|
||||
let mut shared: Vec<&str> = per_movie
|
||||
.iter()
|
||||
.filter(|(_, slots)| slots.len() > 1)
|
||||
.map(|(m, _)| *m)
|
||||
.collect();
|
||||
shared.sort_unstable();
|
||||
assert_eq!(shared, ["hokyu_DS_s07A", "hokyu_DS_s07H", "hokyu_LS_s02A"]);
|
||||
|
||||
// Every id names a real record, so nothing dangles.
|
||||
assert!(entries.iter().all(|e| !e.slot.is_empty() && !e.movie.is_empty()));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user