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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user