From 0cf0cda09edb8c11e468e6f267634b780d00bd4c Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Tue, 25 Aug 2026 23:13:41 +0000 Subject: [PATCH] =?UTF-8?q?re:=20rebuild=20the=20movie=20manifest=20on=20t?= =?UTF-8?q?he=20record=20table=20=E2=80=94=20the=20old=20counts=20measured?= =?UTF-8?q?=20pool=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE --- .../examples/movie_map_csv.rs | 27 +++ crates/sylpheed-formats/src/movie_manifest.rs | 61 +++++- .../tests/movie_manifest_disc.rs | 81 ++++++- docs/re/INDEX.md | 2 +- docs/re/captures/movie-subtitle-voice-map.csv | 207 +++++++++--------- docs/re/movie-subtitle-link.md | 67 ++++-- docs/re/structures/movie-subtitles.md | 2 +- 7 files changed, 313 insertions(+), 134 deletions(-) create mode 100644 crates/sylpheed-formats/examples/movie_map_csv.rs diff --git a/crates/sylpheed-formats/examples/movie_map_csv.rs b/crates/sylpheed-formats/examples/movie_map_csv.rs new file mode 100644 index 00000000..5e1c7325 --- /dev/null +++ b/crates/sylpheed-formats/examples/movie_map_csv.rs @@ -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() + ); + } +} diff --git a/crates/sylpheed-formats/src/movie_manifest.rs b/crates/sylpheed-formats/src/movie_manifest.rs index 3e66964c..30c5fb5e 100644 --- a/crates/sylpheed-formats/src/movie_manifest.rs +++ b/crates/sylpheed-formats/src/movie_manifest.rs @@ -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 { + 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> { + 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 { let toks = ascii_runs(bytes, 3); // Locate the slot-key array (`LOGO1` … first `.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); } diff --git a/crates/sylpheed-formats/tests/movie_manifest_disc.rs b/crates/sylpheed-formats/tests/movie_manifest_disc.rs index 8382e181..c8f149fa 100644 --- a/crates/sylpheed-formats/tests/movie_manifest_disc.rs +++ b/crates/sylpheed-formats/tests/movie_manifest_disc.rs @@ -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_ in \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())); +} diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index 1ca564ef..7f1d76c2 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -17,7 +17,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes | T8aD 2D texture | ✅ | `sylpheed-formats/src/t8ad.rs` | **100 % of the disc decodes** (19 216/19 216, measured). The "~15 % deferred variants" were a wrong model, not a variant: a surface is a list of **arbitrary sub-rectangles**, each with a 16-byte header of `dst X, dst Y, width, height`, not a 256×256 grid — `0x1c` is the **rectangle count**. Uncovered area stays transparent. **Colours ✅ CONFIRMED** ([k8888](structures/texture-color-k8888.md)) | | RATC bundle | ✅ | `sylpheed-formats/src/ratc.rs` | child listing confirmed. **"One level deep" is not a limitation — there is nothing deeper**: 2 859 bundles hold 18 002 children at depth 1 and **0 at depth 2**, with no parse failures. Nested RATC blobs are **leaf records that reference siblings by name** (`opt `, the sprite name): 3 311 leaves, all embedding sibling names, **10 144 of 10 148 references resolve**. The 4 that do not are one dangling asset — `pmbase.rat` → `pmbase.t32` in `GP_STAGE_CLEAR.pak`'s four language builds, and `pmbase.t32` is **on the disc nowhere** | | LSTA sprite list | ✅ | `sylpheed-formats/src/lsta.rs` | A display list of inline elements: **T8aD sprites and `PRMD` primitives**. The `count` at `0x04` is **exact and counts both** — `count == T8aD + PRMD` for **64/64** lists on the disc, which retires the old "a few entries disagree" note (it compared sprites against a total including primitives). **All 1 281 sprite frames decode** after the T8aD rectangle-list fix | -| IXUD subtitle | 🟡/✅ | `sylpheed-formats/src/ixud.rs` + [movie link](movie-subtitle-link.md) | timed cues. **The movie↔subtitle↔voice link is solved — statically**, from the movie config record in `tables.pak` (schema `0x067025b9`), not from the running game as this row previously assumed: [101 movies mapped](captures/movie-subtitle-voice-map.csv), 94 with subtitles, 83 with voice, 21 with a telop overlay. 93 of 94 subtitle refs resolve in the language paks; **`SUBTITLE_S12B.tbl` is missing from all six languages** — a dangling reference on the disc. Naming is `SUBTITLE_.tbl` / `VOICE_` with six documented exceptions. The record's ~104 **script ids** are ❔ — positional pairing drifts by three because the IDXD pool dedupes repeated values | +| IXUD subtitle | 🟡/✅ | `sylpheed-formats/src/ixud.rs` + [movie link](movie-subtitle-link.md) | timed cues. **The movie↔subtitle↔voice link is solved — statically**, and as of 2026-08-25 read from the IDXD **record table** rather than scraped from the string pool: **104 cutscene slots binding 101 distinct movies**, 99 slots / 96 movies with a subtitle, 99 / 96 with a voice track, 22 / 22 with a telop. ⚠️ The previous counts (94 / 83 / 21) were the numbers of **distinct pool strings** — a repeat reference contributes no token, so 13 later `VOICE_D_450..454` references read as "no binding". **All 18 hokyu movies are bound**, not five. 93 of the 94 distinct subtitle members resolve; `SUBTITLE_S12B.tbl` resolves in none of the six languages — a dangling reference on the disc. The ~104 **script ids are no longer ❔**: they are literal positional field keys in `BASE_INFO`, each naming its record, and all 104 resolve. `movie_manifest::parse` now reads the record table; CSV regenerated by `examples/movie_map_csv.rs` | | Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser | | XBG7 mesh | ✅/🟡 | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | **6 294 resources, 6 209 decode (98.7 %), 82 searched-and-missed** (2026-08-12, up from 5 480 / 87.1 %). Five evidence-driven fixes got there: **distinct anchor assignment** (no two resources may claim one buffer — proved by a capture showing the container holds both mirrored `e106` hull halves), the connectivity cap replaced by a **winding-consistency gate at 0.70**, **structural requirements on pre-pivot sub-meshes** (index range, then exact pool coverage), and **filtering after the assignment** so a subset query cannot differ from the full decode. Validated against a runtime capture that names the file offset of every buffer the engine drew: **46/46 drawn buffers claimed, 45 anchored exactly**. **No real mesh now decodes differently in different containers** — all 89 remaining cross-container disagreements are interchangeable 24-vertex bounding boxes, which no anchoring rule can pin (monotone order re-tested and refuted). Remaining misses attribute to the degeneracy/extent gate (42), winding (31) and coverage (9); the first was probed and its "obvious" fix refuted. Every decoded sub-mesh covers its own vertex pool. **The `[index buffer][vertex buffer]` layout is now runtime-verified** (2026-08-13): with the F10 capture extended to log each draw's index buffer, all **42** drawn `Stage_S02` buffers match our decoded index count exactly, all 42 have their index union cover the pool exactly, and the 30 single-block cases all sit at `pad ≤ 3` — so `e106_eng_02_l`'s old rejection was the connectivity gate, not a misplaced index buffer. The `indices=` mystery was the capture keeping only the **first of several index batches** per buffer. **And comparing index VALUES found the biggest silent defect yet**: the anchor took the first `pad` that validated, so a block whose index data sits at pad 2 was read **one element late** — 76/93 captured runs matched, all 17 differences a one-element shift. Scoring pads by degenerate triangles + winding fixes it: **93/93** captured runs now match byte for byte, disc-wide degenerate runs **582 → 1** (the grouped path had the same bug; and two resources were anchored on a degenerate lookalike earlier in file order), **590 of 8 850** sub-meshes re-wired with 10 vertex anchors moved, resources decoded unchanged at 6 209. Cross-container minority decodes 89 → 96 — *because* the decoder improved: `_rou_f402_dead` now has a majority (32×25×8) so its seven wrong copies are named instead of hidden. One dirty run remains, blocked by distinct assignment on a 24-vertex box. **Then the descriptor gave up its last structural secret**: it declares a vertex layout **per sub-mesh** (`n201_01` → strides 24/24/24/**28**, capture-confirmed), and grouped selection must prefer the candidate explaining the **whole** pool rather than the first whose pivot validates — together they take never-decoding resources **85 → 47** (**6 247 / 6 294 = 99.25 %** decode), put `n201_01` on all four capture-proven offsets and raise the stage-05 capture oracle to **128/128**. The residual 47 is 30 pose/proxy composites (0.010-unit marker boxes), 6 `.DAT` particle composites, 8 damage/LOD variants and 3 props — not a threshold away | | Capital-ship part placement | ✅ | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | Placement is **sound** (hull static-exact against the `e106` capture; cross-id mounting genuinely narrow, 2 pairs across 335 ships). The XBG7 mis-decode this row used to blame for "ships assemble wrong" — a shared turret ~100× too large in some containers — is **fixed** (2026-08-12, the exact-coverage requirement): `e303_wep_01` now decodes 49×23×42 everywhere and places at ±179 on the `e106` hull, and no real mesh disagrees across containers. A composite-node audit confirmed the assembler itself never applied a bad scale (all nodes scale 1.0, orthonormal). Still open: `static_assembly_matches_runtime_capture` walks capture parts only, so **extra** static placements cannot fail it | diff --git a/docs/re/captures/movie-subtitle-voice-map.csv b/docs/re/captures/movie-subtitle-voice-map.csv index aa5bb028..af68fb82 100644 --- a/docs/re/captures/movie-subtitle-voice-map.csv +++ b/docs/re/captures/movie-subtitle-voice-map.csv @@ -1,102 +1,105 @@ -movie,telop,subtitle,voicetrack -logo1.wmv,,, -logo2.wmv,,, -logo3.wmv,,, -logo4.wmv,,, -ADV.wmv,,,VOICE_ADV -SYLPH_HD720p_8M-CBR_2ch.wmv,pwterop_s01a.prt,SYLPH_HD720p_8M-CBR_2ch.tbl, -S00A.wmv,,SUBTITLE_S00A.tbl,VOICE_S00A -S01A.wmv,,SUBTITLE_S01A.tbl,VOICE_S01A -RT01A.wmv,pwrt01.prt,SUBTITLE_RT01A.tbl,VOICE_RT01A -RT01B.wmv,,SUBTITLE_RT01B.tbl,VOICE_RT01B -RT01C_1.wmv,,SUBTITLE_RT01C_1.tbl,VOICE_RT01C_1 -RT01C_2.wmv,,SUBTITLE_RT01C_2.tbl,VOICE_RT01C_2 -S02A.wmv,pwterop_s02a.prt,SUBTITLE_S02A.tbl,VOICE_S02A -S02B.wmv,,SUBTITLE_S02B.tbl,VOICE_S02B -S02C.wmv,,SUBTITLE_S02C.tbl,VOICE_S02C -RT02A.wmv,pwrt02.prt,SUBTITLE_RT02A.tbl,VOICE_RT02A -RT02B.wmv,,SUBTITLE_RT02B.tbl,VOICE_RT02B -RT02C.wmv,,SUBTITLE_RT02C.tbl,VOICE_RT02C -RT02D_1.wmv,,SUBTITLE_RT02D_1.tbl,VOICE_RT02D_1 -RT02D_2.wmv,,SUBTITLE_RT02D_2.tbl,VOICE_RT02D_2 -hokyu_LS_s02A.wmv,,SUBTITLE_hokyu_LS_s02A.tbl,VOICE_D_450 -hokyu_LS_s02H.wmv,,SUBTITLE_hokyu_LS_s02H.tbl,VOICE_D_453 -S03A.wmv,,SUBTITLE_S03A.tbl,VOICE_S03A -RT03A.wmv,pwrt03.prt,SUBTITLE_RT03A.tbl,VOICE_RT03A -RT03B.wmv,,SUBTITLE_RT03B.tbl,VOICE_RT03B -RT03C.wmv,,SUBTITLE_RT03C.tbl,VOICE_RT03C -RT03D.wmv,,SUBTITLE_RT03D.tbl,VOICE_RT03D -hokyu_LS_s03A.wmv,,SUBTITLE_hokyu_LS_s03A.tbl, -hokyu_LS_s03H.wmv,,SUBTITLE_hokyu_LS_s03H.tbl, -S04A.wmv,,SUBTITLE_S04A.tbl,VOICE_S04A -S04B.wmv,,SUBTITLE_S04B.tbl,VOICE_S04B -RT04A.wmv,pwrt04.prt,SUBTITLE_RT04A.tbl,VOICE_RT04A -RT04B.wmv,,SUBTITLE_RT04B.tbl,VOICE_RT04B -hokyu_DS_s02A.wmv,,SUBTITLE_hokyu_DS_s02A.tbl,VOICE_D_452 -S05A.wmv,,SUBTITLE_S05A.tbl,VOICE_S05A -RT05A.wmv,pwrt05.prt,SUBTITLE_RT05A.tbl,VOICE_RT05A -RT05B.wmv,,SUBTITLE_RT05B.tbl,VOICE_RT05B -RT05C.wmv,,SUBTITLE_RT05C.tbl,VOICE_RT05C -S06A.wmv,pwterop_s06a.prt,SUBTITLE_S06A.tbl,VOICE_S06A -S06B.wmv,,SUBTITLE_S06B.tbl,VOICE_S06B -RT06A.wmv,pwrt06.prt,SUBTITLE_RT06A.tbl,VOICE_RT06A -RT06B.wmv,,SUBTITLE_RT06B.tbl,VOICE_RT06B -RT06C.wmv,,SUBTITLE_RT06C.tbl,VOICE_RT06C -RT06D.wmv,,SUBTITLE_RT06D.tbl,VOICE_RT06D -hokyu_LS_s06A.wmv,,SUBTITLE_hokyu_LS_s06A.tbl, -hokyu_LS_s06H.wmv,,SUBTITLE_hokyu_LS_s06H.tbl, -S07A.wmv,,SUBTITLE_S07A.tbl,VOICE_S07A -S07B.wmv,,SUBTITLE_S07B.tbl,VOICE_S07B -RT07A.wmv,pwrt07.prt,SUBTITLE_RT07A.tbl,VOICE_RT07A -RT07B.wmv,,SUBTITLE_RT07B.tbl,VOICE_RT07B -RT07C.wmv,,SUBTITLE_RT07C.tbl,VOICE_RT07C -hokyu_DS_s07A.wmv,,SUBTITLE_hokyu_DS_s07A.tbl, -hokyu_DS_s07H.wmv,,SUBTITLE_hokyu_DS_s07H.tbl,VOICE_D_454 -RT08A.wmv,pwrt08.prt,SUBTITLE_RT08A.tbl,VOICE_RT08A -RT08B.wmv,,SUBTITLE_RT08B.tbl,VOICE_RT08B -RT08C.wmv,,SUBTITLE_RT08C.tbl,VOICE_RT08C -hokyu_DS_s08A.wmv,,SUBTITLE_hokyu_DS_s08A.tbl, -S09B.wmv,,SUBTITLE_S09B.tbl,VOICE_S09B -RT09A.wmv,pwrt09.prt,SUBTITLE_RT09A.tbl,VOICE_RT09A -RT09B.wmv,,SUBTITLE_RT09B.tbl,VOICE_RT09B -RT09C.wmv,,SUBTITLE_RT09C.tbl,VOICE_RT09C -RT09D.wmv,,SUBTITLE_RT09D.tbl,VOICE_RT09D -hokyu_LS_s09A.wmv,,SUBTITLE_hokyu_LS_s09A.tbl,VOICE_D_451 -hokyu_LS_s09H.wmv,,SUBTITLE_hokyu_LS_s09H.tbl, -S10B.wmv,,SUBTITLE_S10B.tbl,VOICE_S10B -RT10A.wmv,pwrt10.prt,SUBTITLE_RT10A.tbl,VOICE_RT10A -RT10B.wmv,,SUBTITLE_RT10B.tbl,VOICE_RT10B -S11A.wmv,,SUBTITLE_S11A.tbl,VOICE_S11A -S11C.wmv,,SUBTITLE_S11C.tbl,VOICE_S11C -RT11A.wmv,pwrt11.prt,SUBTITLE_RT11A.tbl,VOICE_RT11A -RT11B.wmv,,SUBTITLE_RT11B.tbl,VOICE_RT11B -RT11C.wmv,,SUBTITLE_RT11C.tbl,VOICE_RT11C -hokyu_LS_s11A.wmv,,SUBTITLE_hokyu_LS_s11A.tbl, -S12A.wmv,,SUBTITLE_S12A.tbl,VOICE_S12A -S12B.wmv,,SUBTITLE_S12B.tbl,VOICE_S12B -S12C.wmv,,SUBTITLE_S12C.tbl,VOICE_S12C -RT12A.wmv,pwrt12.prt,SUBTITLE_RT12A.tbl,VOICE_RT12A -RT12B_1.wmv,,SUBTITLE_RT12B_1.tbl,VOICE_RT12B_1 -RT12B_2.wmv,,SUBTITLE_RT12B_2.tbl,VOICE_RT12B_2 -S13A.wmv,,SUBTITLE_S13A.tbl,VOICE_S13A -S13B.wmv,,SUBTITLE_S13B.tbl,VOICE_S13B -RT13A.wmv,pwrt13.prt,SUBTITLE_RT13A.tbl,VOICE_RT13A -RT13B_1.wmv,,SUBTITLE_RT13B_1.tbl,VOICE_RT13B_1 -RT13B_2.wmv,,SUBTITLE_RT13B_2.tbl,VOICE_RT13B_2 -hokyu_DS_s13A.wmv,,SUBTITLE_hokyu_DS_s13A.tbl, -S14A.wmv,,SUBTITLE_S14A.tbl,VOICE_S14A -RT14A.wmv,pwrt14.prt,SUBTITLE_RT14A.tbl,VOICE_RT14A -RT14B.wmv,,SUBTITLE_RT14B.tbl,VOICE_RT14B -RT14C.wmv,,SUBTITLE_RT14C.tbl,VOICE_RT14C -hokyu_DS_s14H.wmv,,SUBTITLE_hokyu_DS_s14H.tbl, -S15A.wmv,pwterop_s15a.prt,SUBTITLE_S15A.tbl,VOICE_S15A -S15B.wmv,,SUBTITLE_S15B.tbl,VOICE_S15B -S15C.wmv,,SUBTITLE_S15C.tbl,VOICE_S15C -RT15A.wmv,pwrt15.prt,SUBTITLE_RT15A.tbl,VOICE_RT15A -RT15B.wmv,,SUBTITLE_RT15B.tbl,VOICE_RT15B -RT15C.wmv,,SUBTITLE_RT15C.tbl,VOICE_RT15C -hokyu_LS_s15A.wmv,,SUBTITLE_hokyu_LS_s15A.tbl, -S16A.wmv,pwterop_s16a.prt,SUBTITLE_S16A.tbl,VOICE_S16A -RT16C.wmv,pwrt16.prt,SUBTITLE_RT16C.tbl,VOICE_RT16C -hokyu_LS_s24A.wmv,,, -hokyu_LS_s27A.wmv,,, +slot,movie,telop,subtitle,voicetrack +LOGO1,logo1.wmv,,, +LOGO2,logo2.wmv,,, +LOGO3,logo3.wmv,,, +LOGO4,logo4.wmv,,, +ADVERTISE_MOVIE,ADV.wmv,,,VOICE_ADV +STAFF_ROLL,SYLPH_HD720p_8M-CBR_2ch.wmv,pwterop_s01a.prt,SYLPH_HD720p_8M-CBR_2ch.tbl, +MS00A,S00A.wmv,,SUBTITLE_S00A.tbl,VOICE_S00A +MS01A,S01A.wmv,pwterop_s01a.prt,SUBTITLE_S01A.tbl,VOICE_S01A +STAGE01_PHASE01,RT01A.wmv,pwrt01.prt,SUBTITLE_RT01A.tbl,VOICE_RT01A +STAGE01_PHASE02,RT01B.wmv,,SUBTITLE_RT01B.tbl,VOICE_RT01B +STAGE01_PHASE_END_01,RT01C_1.wmv,,SUBTITLE_RT01C_1.tbl,VOICE_RT01C_1 +STAGE01_PHASE_END_02,RT01C_2.wmv,,SUBTITLE_RT01C_2.tbl,VOICE_RT01C_2 +MS02A,S02A.wmv,pwterop_s02a.prt,SUBTITLE_S02A.tbl,VOICE_S02A +MS02B,S02B.wmv,,SUBTITLE_S02B.tbl,VOICE_S02B +MS02C,S02C.wmv,,SUBTITLE_S02C.tbl,VOICE_S02C +STAGE02_PHASE01,RT02A.wmv,pwrt02.prt,SUBTITLE_RT02A.tbl,VOICE_RT02A +STAGE02_PHASE02,RT02B.wmv,,SUBTITLE_RT02B.tbl,VOICE_RT02B +STAGE02_PHASE03,RT02C.wmv,,SUBTITLE_RT02C.tbl,VOICE_RT02C +STAGE02_PHASE_END_01,RT02D_1.wmv,,SUBTITLE_RT02D_1.tbl,VOICE_RT02D_1 +STAGE02_PHASE_END_02,RT02D_2.wmv,,SUBTITLE_RT02D_2.tbl,VOICE_RT02D_2 +S02_SUPPLY_ACROPOLIS,hokyu_LS_s02A.wmv,,SUBTITLE_hokyu_LS_s02A.tbl,VOICE_D_450 +S02_SUPPLY_TANKER,hokyu_LS_s02H.wmv,,SUBTITLE_hokyu_LS_s02H.tbl,VOICE_D_453 +MS03A,S03A.wmv,,SUBTITLE_S03A.tbl,VOICE_S03A +STAGE03_PHASE01,RT03A.wmv,pwrt03.prt,SUBTITLE_RT03A.tbl,VOICE_RT03A +STAGE03_PHASE02,RT03B.wmv,,SUBTITLE_RT03B.tbl,VOICE_RT03B +STAGE03_PHASE03,RT03C.wmv,,SUBTITLE_RT03C.tbl,VOICE_RT03C +STAGE03_PHASE_END,RT03D.wmv,,SUBTITLE_RT03D.tbl,VOICE_RT03D +S03_SUPPLY_ACROPOLIS,hokyu_LS_s03A.wmv,,SUBTITLE_hokyu_LS_s03A.tbl,VOICE_D_450 +S03_SUPPLY_TANKER,hokyu_LS_s03H.wmv,,SUBTITLE_hokyu_LS_s03H.tbl,VOICE_D_453 +MS04A,S04A.wmv,,SUBTITLE_S04A.tbl,VOICE_S04A +MS04B,S04B.wmv,,SUBTITLE_S04B.tbl,VOICE_S04B +STAGE04_PHASE01,RT04A.wmv,pwrt04.prt,SUBTITLE_RT04A.tbl,VOICE_RT04A +STAGE04_PHASE02,RT04B.wmv,,SUBTITLE_RT04B.tbl,VOICE_RT04B +S04_SUPPLY_ACROPOLIS,hokyu_DS_s02A.wmv,,SUBTITLE_hokyu_DS_s02A.tbl,VOICE_D_452 +MS05A,S05A.wmv,,SUBTITLE_S05A.tbl,VOICE_S05A +STAGE05_PHASE01,RT05A.wmv,pwrt05.prt,SUBTITLE_RT05A.tbl,VOICE_RT05A +STAGE05_PHASE02,RT05B.wmv,,SUBTITLE_RT05B.tbl,VOICE_RT05B +STAGE05_PHASE_END,RT05C.wmv,,SUBTITLE_RT05C.tbl,VOICE_RT05C +S05_SUPPLY_ACROPOLIS,hokyu_LS_s02A.wmv,,SUBTITLE_hokyu_LS_s02A.tbl,VOICE_D_450 +MS06A,S06A.wmv,pwterop_s06a.prt,SUBTITLE_S06A.tbl,VOICE_S06A +MS06B,S06B.wmv,,SUBTITLE_S06B.tbl,VOICE_S06B +STAGE06_PHASE01,RT06A.wmv,pwrt06.prt,SUBTITLE_RT06A.tbl,VOICE_RT06A +STAGE06_PHASE02,RT06B.wmv,,SUBTITLE_RT06B.tbl,VOICE_RT06B +STAGE06_PHASE03,RT06C.wmv,,SUBTITLE_RT06C.tbl,VOICE_RT06C +STAGE06_PHASE_END,RT06D.wmv,,SUBTITLE_RT06D.tbl,VOICE_RT06D +S06_SUPPLY_ACROPOLIS,hokyu_LS_s06A.wmv,,SUBTITLE_hokyu_LS_s06A.tbl,VOICE_D_450 +S06_SUPPLY_TANKER,hokyu_LS_s06H.wmv,,SUBTITLE_hokyu_LS_s06H.tbl,VOICE_D_453 +MS07A,S07A.wmv,,SUBTITLE_S07A.tbl,VOICE_S07A +MS07B,S07B.wmv,,SUBTITLE_S07B.tbl,VOICE_S07B +STAGE07_PHASE01,RT07A.wmv,pwrt07.prt,SUBTITLE_RT07A.tbl,VOICE_RT07A +STAGE07_PHASE02,RT07B.wmv,,SUBTITLE_RT07B.tbl,VOICE_RT07B +STAGE07_PHASE_END,RT07C.wmv,,SUBTITLE_RT07C.tbl,VOICE_RT07C +S07_SUPPLY_ACROPOLIS,hokyu_DS_s07A.wmv,,SUBTITLE_hokyu_DS_s07A.tbl,VOICE_D_452 +S07_SUPPLY_TANKER,hokyu_DS_s07H.wmv,,SUBTITLE_hokyu_DS_s07H.tbl,VOICE_D_454 +STAGE08_PHASE01,RT08A.wmv,pwrt08.prt,SUBTITLE_RT08A.tbl,VOICE_RT08A +STAGE08_PHASE02,RT08B.wmv,,SUBTITLE_RT08B.tbl,VOICE_RT08B +STAGE08_PHASE_END,RT08C.wmv,,SUBTITLE_RT08C.tbl,VOICE_RT08C +S08_SUPPLY_ACROPOLIS,hokyu_DS_s08A.wmv,,SUBTITLE_hokyu_DS_s08A.tbl,VOICE_D_452 +MS09B,S09B.wmv,,SUBTITLE_S09B.tbl,VOICE_S09B +STAGE09_PHASE01,RT09A.wmv,pwrt09.prt,SUBTITLE_RT09A.tbl,VOICE_RT09A +STAGE09_PHASE02,RT09B.wmv,,SUBTITLE_RT09B.tbl,VOICE_RT09B +STAGE09_PHASE03,RT09C.wmv,,SUBTITLE_RT09C.tbl,VOICE_RT09C +STAGE09_PHASE_END,RT09D.wmv,,SUBTITLE_RT09D.tbl,VOICE_RT09D +S09_SUPPLY_ACROPOLIS,hokyu_LS_s09A.wmv,,SUBTITLE_hokyu_LS_s09A.tbl,VOICE_D_451 +S09_SUPPLY_TANKER,hokyu_LS_s09H.wmv,,SUBTITLE_hokyu_LS_s09H.tbl,VOICE_D_453 +MS10B,S10B.wmv,,SUBTITLE_S10B.tbl,VOICE_S10B +STAGE10_PHASE01,RT10A.wmv,pwrt10.prt,SUBTITLE_RT10A.tbl,VOICE_RT10A +STAGE10_PHASE_END,RT10B.wmv,,SUBTITLE_RT10B.tbl,VOICE_RT10B +MS11A,S11A.wmv,,SUBTITLE_S11A.tbl,VOICE_S11A +MS11C,S11C.wmv,,SUBTITLE_S11C.tbl,VOICE_S11C +STAGE11_PHASE01,RT11A.wmv,pwrt11.prt,SUBTITLE_RT11A.tbl,VOICE_RT11A +STAGE11_PHASE02,RT11B.wmv,,SUBTITLE_RT11B.tbl,VOICE_RT11B +STAGE11_PHASE_END,RT11C.wmv,,SUBTITLE_RT11C.tbl,VOICE_RT11C +S11_SUPPLY_ACROPOLIS,hokyu_LS_s11A.wmv,,SUBTITLE_hokyu_LS_s11A.tbl,VOICE_D_451 +MS12A,S12A.wmv,,SUBTITLE_S12A.tbl,VOICE_S12A +MS12B,S12B.wmv,,SUBTITLE_S12B.tbl,VOICE_S12B +MS12C,S12C.wmv,,SUBTITLE_S12C.tbl,VOICE_S12C +STAGE12_PHASE01,RT12A.wmv,pwrt12.prt,SUBTITLE_RT12A.tbl,VOICE_RT12A +STAGE12_PHASE_END_01,RT12B_1.wmv,,SUBTITLE_RT12B_1.tbl,VOICE_RT12B_1 +STAGE12_PHASE_END_02,RT12B_2.wmv,,SUBTITLE_RT12B_2.tbl,VOICE_RT12B_2 +S12_SUPPLY_ACROPOLIS,hokyu_DS_s07A.wmv,,SUBTITLE_hokyu_DS_s07A.tbl,VOICE_D_452 +S12_SUPPLY_TANKER,hokyu_DS_s07H.wmv,,SUBTITLE_hokyu_DS_s07H.tbl,VOICE_D_454 +MS13A,S13A.wmv,,SUBTITLE_S13A.tbl,VOICE_S13A +MS13B,S13B.wmv,,SUBTITLE_S13B.tbl,VOICE_S13B +STAGE13_PHASE01,RT13A.wmv,pwrt13.prt,SUBTITLE_RT13A.tbl,VOICE_RT13A +STAGE13_PHASE_END_01,RT13B_1.wmv,,SUBTITLE_RT13B_1.tbl,VOICE_RT13B_1 +STAGE13_PHASE_END_02,RT13B_2.wmv,,SUBTITLE_RT13B_2.tbl,VOICE_RT13B_2 +S13_SUPPLY_ACROPOLIS,hokyu_DS_s13A.wmv,,SUBTITLE_hokyu_DS_s13A.tbl,VOICE_D_452 +MS14A,S14A.wmv,,SUBTITLE_S14A.tbl,VOICE_S14A +STAGE14_PHASE01,RT14A.wmv,pwrt14.prt,SUBTITLE_RT14A.tbl,VOICE_RT14A +STAGE14_PHASE02,RT14B.wmv,,SUBTITLE_RT14B.tbl,VOICE_RT14B +STAGE14_PHASE_END,RT14C.wmv,,SUBTITLE_RT14C.tbl,VOICE_RT14C +S14_SUPPLY_TANKER,hokyu_DS_s14H.wmv,,SUBTITLE_hokyu_DS_s14H.tbl,VOICE_D_454 +MS15A,S15A.wmv,pwterop_s15a.prt,SUBTITLE_S15A.tbl,VOICE_S15A +MS15B,S15B.wmv,,SUBTITLE_S15B.tbl,VOICE_S15B +MS15C,S15C.wmv,,SUBTITLE_S15C.tbl,VOICE_S15C +STAGE15_PHASE01,RT15A.wmv,pwrt15.prt,SUBTITLE_RT15A.tbl,VOICE_RT15A +STAGE15_PHASE02,RT15B.wmv,,SUBTITLE_RT15B.tbl,VOICE_RT15B +STAGE15_PHASE_END,RT15C.wmv,,SUBTITLE_RT15C.tbl,VOICE_RT15C +S15_SUPPLY_ACROPOLIS,hokyu_LS_s15A.wmv,,SUBTITLE_hokyu_LS_s15A.tbl,VOICE_D_451 +MS16A,S16A.wmv,pwterop_s16a.prt,SUBTITLE_S16A.tbl,VOICE_S16A +STAGE16_PHASE01,RT16C.wmv,pwrt16.prt,SUBTITLE_RT16C.tbl,VOICE_RT16C +S24_SUPPLY_ACROPOLIS,hokyu_LS_s24A.wmv,,SUBTITLE_hokyu_LS_s11A.tbl,VOICE_D_451 +S27_SUPPLY_ACROPOLIS,hokyu_LS_s27A.wmv,,SUBTITLE_hokyu_LS_s11A.tbl,VOICE_D_451 diff --git a/docs/re/movie-subtitle-link.md b/docs/re/movie-subtitle-link.md index 2aa8c03c..ba7fdf50 100644 --- a/docs/re/movie-subtitle-link.md +++ b/docs/re/movie-subtitle-link.md @@ -30,36 +30,59 @@ VOICE_S02A VOICETRACK language paks (`eng`, `fra`, `deu`, `esp`, `ita`, `jpn`) each carry the same member names — so the prefix in the table is just the build's language and the member name is the portable part. The recovered map is -[`captures/movie-subtitle-voice-map.csv`](captures/movie-subtitle-voice-map.csv): -**101 movies, 94 with a subtitle, 83 with a voice track, 21 with a telop.** +[`captures/movie-subtitle-voice-map.csv`](captures/movie-subtitle-voice-map.csv), +now regenerated from the **record table** (`examples/movie_map_csv.rs`) and keyed +by cutscene **slot**: + +**104 slots binding 101 distinct movies.** 99 slots / 96 movies have a subtitle, +99 slots / 96 movies have a voice track, 22 slots / 22 movies have a telop — +drawing on 94 distinct subtitle tables, 83 distinct voice banks and 21 distinct +telop overlays. + +### ❌ The old counts were measuring the wrong thing + +This file used to say *"101 movies, 94 with a subtitle, 83 with a voice track, 21 +with a telop"*. Those three numbers are exactly the counts of **distinct pool +strings** — which is all a string-pool scraper can see. The pool stores each +value once, so a *repeat* reference contributes no token and read as "no +binding": 13 later references to `VOICE_D_450..454`, two to +`SUBTITLE_hokyu_LS_s11A.tbl`, and `MS01A`'s share of `pwterop_s01a.prt`. + +Slots, movies and distinct strings are three different denominators and this file +conflated them. Pinned by `manifest_slot_and_movie_counts`. ## Verified, and one real defect -Every subtitle reference was looked up in the language paks. **93 of 94 resolve. -`SUBTITLE_S12B.tbl` resolves in none of the six languages** — a dangling -reference on the retail disc, not a decode failure on our side. Worth knowing -before the reimplementation treats a missing subtitle table as a bug of its own. +Every subtitle reference was looked up in the language paks. **93 of the 94 +distinct members resolve. `SUBTITLE_S12B.tbl` resolves in none of the six +languages** — a dangling reference on the retail disc, not a decode failure on +our side. Worth knowing before the reimplementation treats a missing subtitle +table as a bug of its own. -The naming is a convention with two documented exceptions: +The naming is a convention, with more exceptions than were recorded: -- subtitles are `SUBTITLE_.tbl` — the only departure is the staff - roll, `SYLPH_HD720p_8M-CBR_2ch.wmv` → `SYLPH_HD720p_8M-CBR_2ch.tbl`; -- voice tracks are `VOICE_` — except five supply-run movies - (`hokyu_LS_s02A`, `hokyu_LS_s02H`, `hokyu_DS_s02A`, `hokyu_DS_s07H`, - `hokyu_LS_s09A`), which point at shared lines `VOICE_D_450…454`. +- subtitles are `SUBTITLE_.tbl`, with **3** departures — the staff + roll (`SYLPH_HD720p_8M-CBR_2ch.wmv` → `SYLPH_HD720p_8M-CBR_2ch.tbl`), and + `hokyu_LS_s24A` / `hokyu_LS_s27A`, which **borrow `SUBTITLE_hokyu_LS_s11A.tbl`**; +- voice tracks are `VOICE_` except **all 18 hokyu movies** (21 supply + slots), which share `VOICE_D_450…454`. The old text said five; the other 13 + were invisible to the scraper for the dedup reason above. -So a reimplementation can resolve a movie's subtitle and voice **by convention**, -and should fall back to this table for those six cases rather than assuming it. +So a reimplementation should read this table, **not** apply the convention with a +short exception list. -## What is *not* recoverable from this record +## ✅ The script ids ARE recoverable — the old ❔ is closed + +The record also lists 104 **script ids** — the names stage scripts use to trigger +a movie. This file used to say pairing them with the movie groups "does not +work: the counts differ by three". The counts differ by three for a concrete +reason: **three resupply movies are bound by two slots each** +(`hokyu_LS_s02A` by S02 and S05, `hokyu_DS_s07A` and `hokyu_DS_s07H` by S07 and +S12). Positional pairing was never the right model — the ids are stored as +**literal positional field keys** in `BASE_INFO`, each naming its record +directly, so every one of the 104 resolves with nothing to infer. The id is +`stage*100 + slot`, with 91/92 for `_SUPPLY_ACROPOLIS`/`_SUPPLY_TANKER`. -The record also lists ~104 **script ids** (`LOGO1`, `ADVERTISE_MOVIE`, -`STAFF_ROLL`, `MS00A`, `STAGE01_PHASE01`, `S02_SUPPLY_ACROPOLIS`, …) — the names -stage scripts use to trigger a movie. Pairing them positionally with the 101 -movie groups **does not work**: the counts differ by three and the drift is -visible at the tail, where the last ids (`S24_SUPPLY_ACROPOLIS`, -`S27_SUPPLY_ACROPOLIS`) would have to map to `hokyu_LS_s24A.wmv` / -`hokyu_LS_s27A.wmv` and under positional pairing do not. The cause is the same IDXD property that bit the Arsenal table: **the string pool stores each distinct string once**, so an id whose movie was already named diff --git a/docs/re/structures/movie-subtitles.md b/docs/re/structures/movie-subtitles.md index 3106696d..a2b2be29 100644 --- a/docs/re/structures/movie-subtitles.md +++ b/docs/re/structures/movie-subtitles.md @@ -73,7 +73,7 @@ guessing both misses real bindings and invents tracks for silent movies: - **5 `hokyu_*` resupply movies bind to in-mission radio clips** — e.g. `hokyu_LS_s02A → VOICE_D_450`, which lives in `\etc\`, *not* `Movie`. A `VOICE_` guess would never find these. -- **18 movies have no direct voice token** = 4 boot logos + 1 HD test pattern + +- **❌ WITHDRAWN — 18 movies have no direct voice token** = 4 boot logos + 1 HD test pattern + **13 `hokyu_*` movies** (incl. `hokyu_DS_s13A`). Only the manifest's **direct** bindings are trusted for playback.