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

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