Before this commit, `unset SYLPHEED_DISC` did not disable the disc-backed
suites on the machine that has the disc: every `disc_root()` fell back to a
hardcoded absolute path that exists on this box. The env var looked like a
control and was not one. Same for $SYLPHEED_RES3D and $SYLPHEED_ISO.
Replace the duplicated resolvers with one `tests/common/mod.rs`:
- 17 local `disc_root()` definitions -> 1
- 7 copies of the skip macro -> 1 (`skip_without_disc!` and siblings)
- 16 hardcoded absolute paths -> 0 executable ones
(3 of those were inline in `mesh_disc.rs`, in no resolver at all,
and 2 were in `examples/`)
- `corpus_report.rs` now reports on the SAME resolver the suites use,
instead of a second copy of the logic its own comments flagged as a
drift risk.
The 17 copies had already drifted into FIVE variants, and they were not all
the same function. `movie_manifest_disc`, `movie_subtitle_disc` and `slb_disc`
honoured $SYLPHEED_DISC and nothing else, while the other 14 fell back. So one
name already meant two things -- a third instance of the shape #16 is about.
The shared helper adopts the env-only behaviour those three already had, rather
than inventing a sixth variant.
Two module docs still described the fallback after it was deleted, which is the
same defect in prose: `texture_disc` claimed "or the default dev path exists"
and `pak_idxd_disc` said "or drop it at the default dev path below". Both now
say what the code does.
Verified both ways on the machine that HAS the corpus, which is the only place
this refactor can be falsified:
A env unset -> "ABSENT -- $SYLPHEED_DISC unset; its suites self-skip"
suites=31 passed=209 failed=0 ignored=14, slowest 0.12s
B env set -> "PRESENT via $SYLPHEED_DISC" (all three corpora)
suites=31 passed=209 failed=0 ignored=14,
slowest 1235.53s (mesh_consistency_disc)
Identical tallies, opposite corpus states, ~10000x apart in wall clock. (A) is
new behaviour -- it was previously unreachable here. (B) proves nothing broke.
`just test-disc` sources `.env` (already gitignored) for the set case. Note the
quoting trap documented there: the corpus paths contain spaces, and an unquoted
`VAR=a b c` parses as "run command `b`", failing silently into ABSENT -- which
looks exactly like a working skip.
Remedy (1) (`#[ignore]` + `--ignored`) is deliberately NOT done here: (3) already
moves the mode from the filesystem into the environment, and `#[ignore]` already
carries three meanings in this directory (corpus-absent, known-failing, bare).
Overloading it a fourth time would re-create the defect.
`cargo fmt --all --check` clean; no new compiler warnings.
Refs #16
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
246 lines
9.1 KiB
Rust
246 lines
9.1 KiB
Rust
//! Real-disc test for the movie manifest → voice binding. Skipped without
|
||
//! `SYLPHEED_DISC`.
|
||
|
||
use std::path::PathBuf;
|
||
|
||
use sylpheed_formats::movie_manifest;
|
||
use sylpheed_formats::slb::VoiceLang;
|
||
use sylpheed_formats::PakArchive;
|
||
|
||
mod common;
|
||
use common::disc_root;
|
||
|
||
/// Read the manifest + `eng\sounds.tbl` out of `tables.pak`.
|
||
fn load_manifest_and_sounds(root: &PathBuf) -> (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");
|
||
}
|