80 findings, not the 14 the first run showed -- clippy stops at the first failing compilation unit, so `--keep-going` is what makes the list complete. 60 were machine-applicable (`cargo clippy --fix`). The rest by hand: * five descending `sort_by` -> `sort_by_key(Reverse(..))` * `chunks_exact(4)` on both sides of four zips, so the compared items stay `[u8; 4]` rather than one array against one slice * three `type` aliases for the census maps and the captured-quad tuple * `&PathBuf` -> `&Path` in two disc tests * two range loops; one of them keeps `#[allow(needless_range_loop)]` with the reason -- the index is into a map's value, which changes each iteration * the module doc list in `invert_capture` re-indented to markdown's rules * `blit`'s eight arguments get `#[allow(too_many_arguments)]`, not a struct One dead `let off = b.len();` in a `ratc` test is dropped rather than renamed. The sibling test at :162 is the one that asserts an offset; if this one was meant to as well, that is a test change and not a lint fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
248 lines
9.2 KiB
Rust
248 lines
9.2 KiB
Rust
//! Real-disc test for the movie manifest → voice binding. Skipped without
|
||
//! `SYLPHEED_DISC`.
|
||
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use sylpheed_formats::movie_manifest;
|
||
use sylpheed_formats::slb::VoiceLang;
|
||
use sylpheed_formats::PakArchive;
|
||
|
||
fn disc_root() -> Option<PathBuf> {
|
||
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
|
||
p.join("dat").is_dir().then_some(p)
|
||
}
|
||
|
||
/// Read the manifest + `eng\sounds.tbl` out of `tables.pak`.
|
||
fn load_manifest_and_sounds(root: &Path) -> (Vec<u8>, Vec<u8>) {
|
||
let pak = PakArchive::open(root.join("dat/tables.pak")).unwrap();
|
||
let manifest = pak
|
||
.entries()
|
||
.iter()
|
||
.find_map(|e| pak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
|
||
.expect("movie manifest present in tables.pak");
|
||
let sounds = pak
|
||
.read_by_name("eng\\sounds.tbl")
|
||
.expect("sounds.tbl present")
|
||
.unwrap();
|
||
(manifest, sounds)
|
||
}
|
||
|
||
#[test]
|
||
fn binds_and_resolves_movie_voice() {
|
||
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);
|
||
// 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!(
|
||
movie_manifest::voice_token(&manifest, "S13A").as_deref(),
|
||
Some("VOICE_S13A")
|
||
);
|
||
assert_eq!(
|
||
movie_manifest::resolve_voice_entry(&manifest, &sounds, "S13A", VoiceLang::English)
|
||
.as_deref(),
|
||
Some("eng\\Movie\\VOICE_S13A.slb")
|
||
);
|
||
|
||
// A resupply movie bound to an in-mission radio clip → VOICE_D_* in \etc\.
|
||
// (This is what a `VOICE_<movie>` guess would miss entirely.)
|
||
assert_eq!(
|
||
movie_manifest::voice_token(&manifest, "hokyu_LS_s02A").as_deref(),
|
||
Some("VOICE_D_450")
|
||
);
|
||
assert_eq!(
|
||
movie_manifest::resolve_voice_entry(
|
||
&manifest,
|
||
&sounds,
|
||
"hokyu_LS_s02A",
|
||
VoiceLang::English
|
||
)
|
||
.as_deref(),
|
||
Some("eng\\etc\\VOICE_D_450.slb")
|
||
);
|
||
|
||
// ❌ 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.as_deref(), Some("VOICE_D_452"));
|
||
assert_eq!(
|
||
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.
|
||
let snd = std::fs::read(root.join("dat/sound.pak")).unwrap();
|
||
let keys: std::collections::HashSet<u32> = PakArchive::parse_toc(&snd)
|
||
.unwrap()
|
||
.iter()
|
||
.map(|e| e.name_hash)
|
||
.collect();
|
||
let mut resolved = 0;
|
||
let mut missing = Vec::new();
|
||
for e in &entries {
|
||
if let Some(full) =
|
||
movie_manifest::resolve_voice_entry(&manifest, &sounds, &e.movie, VoiceLang::English)
|
||
{
|
||
resolved += 1;
|
||
if !keys.contains(&sylpheed_formats::hash::name_hash(&full)) {
|
||
missing.push(full);
|
||
}
|
||
}
|
||
}
|
||
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()));
|
||
}
|
||
|
||
/// Everything the Cutscenes browser shows, asserted against the disc.
|
||
///
|
||
/// The window's value is that it answers "which cutscenes exist, which mission
|
||
/// is each one in, and can I read it without playing it" — so the test checks
|
||
/// exactly those three, including the **negative**: five manifest-bound movies
|
||
/// have no `.wmv`, and nine more have no English transcript. A browser that
|
||
/// quietly omitted them would look complete and be wrong, so the counts are
|
||
/// pinned here rather than left to the eye.
|
||
#[test]
|
||
fn cutscene_catalog_binds_movies_and_transcripts() {
|
||
let Some(root) = disc_root() else {
|
||
eprintln!("SKIP: set SYLPHEED_DISC");
|
||
return;
|
||
};
|
||
use sylpheed_formats::movie_subtitle as ms;
|
||
let (manifest, _) = load_manifest_and_sounds(&root);
|
||
let rows = movie_manifest::parse(&manifest);
|
||
assert_eq!(rows.len(), 104, "manifest slots");
|
||
|
||
let movies: std::collections::BTreeSet<&str> = rows.iter().map(|r| r.movie.as_str()).collect();
|
||
assert_eq!(movies.len(), 101, "distinct movies");
|
||
assert_eq!(rows.iter().filter(|r| r.subtitle.is_some()).count(), 99);
|
||
assert_eq!(rows.iter().filter(|r| r.voice_token.is_some()).count(), 99);
|
||
assert_eq!(rows.iter().filter(|r| r.telop.is_some()).count(), 22);
|
||
|
||
// Bound-but-absent: the four boot logos and one encoder test clip are named
|
||
// by the manifest and are not on the disc. The browser marks these in red
|
||
// rather than offering a Play button that would fail.
|
||
let on_disc: std::collections::BTreeSet<String> = std::fs::read_dir(root.join("dat/movie"))
|
||
.unwrap()
|
||
.filter_map(|e| {
|
||
let p = e.ok()?.path();
|
||
(p.extension()?.to_str()? == "wmv")
|
||
.then(|| p.file_stem()?.to_str().map(|s| s.to_ascii_lowercase()))?
|
||
})
|
||
.collect();
|
||
let mut absent: Vec<&str> = movies
|
||
.iter()
|
||
.copied()
|
||
.filter(|m| !on_disc.contains(&m.to_ascii_lowercase()))
|
||
.collect();
|
||
absent.sort_unstable();
|
||
assert_eq!(
|
||
absent,
|
||
[
|
||
"SYLPH_HD720p_8M-CBR_2ch",
|
||
"logo1",
|
||
"logo2",
|
||
"logo3",
|
||
"logo4"
|
||
],
|
||
"manifest-bound movies with no .wmv on the disc"
|
||
);
|
||
|
||
// Transcripts, which is what makes a cutscene readable without playback.
|
||
let lang_pak = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
|
||
let text_pak = PakArchive::open(root.join("dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||
let resolved = movies
|
||
.iter()
|
||
.filter(|m| !ms::load(m, &lang_pak, &text_pak).is_empty())
|
||
.count();
|
||
assert_eq!(resolved, 92, "movies with an English transcript");
|
||
}
|