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:
Sylpheed RE agent
2026-08-25 23:13:41 +00:00
parent 49c00e0955
commit fedb31a5f9
7 changed files with 313 additions and 134 deletions

View 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()
);
}
}

View File

@@ -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);
}

View File

@@ -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()));
}