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>
308 lines
12 KiB
Rust
308 lines
12 KiB
Rust
//! Integration tests against the real extracted Project Sylpheed disc.
|
||
//!
|
||
//! These are **skipped** (pass as no-ops) when the extracted disc is not present,
|
||
//! so the suite still runs on machines/CI without the game. Point at the disc
|
||
//! with the `SYLPHEED_DISC` env var; there is no path fallback (see #16).
|
||
|
||
use sylpheed_formats::texture::X360Texture;
|
||
use sylpheed_formats::{IdxdObject, PakArchive};
|
||
|
||
mod common;
|
||
use common::skip_without_disc;
|
||
|
||
/// Locate the extracted disc root, or `None` to skip.
|
||
#[test]
|
||
fn deftables_header_and_first_entry() {
|
||
skip_without_disc!(root);
|
||
let arc = PakArchive::open(root.join("hidden/DefTables.pak")).unwrap();
|
||
assert_eq!(arc.len(), 1465);
|
||
assert_eq!(arc.block_size, 0x800);
|
||
assert_eq!(arc.flags, 0x1000_0000);
|
||
// TOC is sorted ascending by name_hash.
|
||
assert!(arc
|
||
.entries()
|
||
.windows(2)
|
||
.all(|w| w[0].name_hash < w[1].name_hash));
|
||
// First entries decompress to a known inner format (IDXD or XML).
|
||
let first = &arc.entries()[0];
|
||
let payload = arc.read(first).unwrap();
|
||
assert!(
|
||
payload.starts_with(b"IDXD") || payload.starts_with(b"<?xml"),
|
||
"unexpected inner magic: {:?}",
|
||
&payload[..4.min(payload.len())]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn deltasaber_craft_stats() {
|
||
skip_without_disc!(root);
|
||
let arc = PakArchive::open(root.join("dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||
// rou_f001 "DeltaSaber" — the player craft. Entry hash discovered via content scan.
|
||
let bytes = arc
|
||
.read_by_hash(0x7c96_296c)
|
||
.expect("entry present")
|
||
.unwrap();
|
||
let obj = IdxdObject::parse(&bytes).unwrap();
|
||
// identifier-valued fields via get_raw
|
||
assert_eq!(obj.get_raw("Type"), Some("Craft"));
|
||
assert_eq!(obj.get_raw("Model"), Some("rou_f001"));
|
||
// numeric flight/combat constants via the reliable typed getters
|
||
assert_eq!(obj.get_f32("Size_X"), Some(10.0));
|
||
assert_eq!(obj.get_f32("Size_Y"), Some(7.0));
|
||
assert_eq!(obj.get_f32("Size_Z"), Some(29.0));
|
||
assert_eq!(obj.get_f32("HP"), Some(1000.0)); // now bound (robust pool start)
|
||
assert_eq!(obj.get_f32("MinimumVelocity"), Some(100.0));
|
||
assert_eq!(obj.get_f32("MaximumVelocity"), Some(1200.0));
|
||
assert_eq!(obj.get_f32("CruisingVelocity"), Some(700.0));
|
||
assert_eq!(obj.get_f32("Acceleration"), Some(600.0));
|
||
assert_eq!(obj.get_f32("Deceleration"), Some(400.0));
|
||
assert_eq!(obj.get_i64("Turn_AngularVelocity"), Some(100));
|
||
assert_eq!(obj.get_f32("ChargeSpeed"), Some(800.0));
|
||
assert_eq!(obj.get_f32("RadarRange"), Some(500000.0));
|
||
// ❌ NOT a defaulted field — this assertion used to claim it was. The record
|
||
// table says ShieldRatio = 1.0; the legacy string-pool reader simply cannot
|
||
// see it. Kept as a *characterisation* of that reader, with the truth beside
|
||
// it, so the blind spot stays visible instead of being read as "no value".
|
||
assert_eq!(obj.get_f32("ShieldRatio"), None);
|
||
let generic = obj.record("Generic").expect("Generic record");
|
||
assert_eq!(generic.get("ShieldRatio"), Some("1.0"));
|
||
// Same for the module docs' canonical "field with no value".
|
||
assert_eq!(generic.get("FCSRange"), Some("500000.0"));
|
||
|
||
// The values above are real, but each lives in a *different* record, which
|
||
// the flat API cannot express: these three are Generic / Maneuver / Shield.
|
||
assert_eq!(generic.get("HP"), Some("1000.0"));
|
||
assert_eq!(
|
||
obj.record("Maneuver").unwrap().get("Acceleration"),
|
||
Some("600.0")
|
||
);
|
||
assert_eq!(
|
||
obj.record("Shield").unwrap().get("ChargeSpeed"),
|
||
Some("800.0")
|
||
);
|
||
|
||
// And `HP` is not one number: 63 turret records carry their own (all 100.0).
|
||
let turrets = obj
|
||
.records()
|
||
.unwrap()
|
||
.iter()
|
||
.filter(|r| r.name.starts_with("Turret_"))
|
||
.count();
|
||
assert_eq!(turrets, 63);
|
||
assert_eq!(obj.record("Turret_000").unwrap().get("HP"), Some("100.0"));
|
||
}
|
||
|
||
/// The legacy reader returns one object-wide answer for a per-record field, and
|
||
/// it is silently *wrong* rather than absent. The hangar's weapon → model map is
|
||
/// the clearest case: every record reads back as the first one.
|
||
#[test]
|
||
fn legacy_reader_flattens_per_record_fields() {
|
||
skip_without_disc!(root);
|
||
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
|
||
let bytes = arc
|
||
.read_by_hash(0x8f72_ddde)
|
||
.expect("entry present")
|
||
.unwrap();
|
||
let obj = IdxdObject::parse(&bytes).unwrap();
|
||
|
||
// One answer for the whole object …
|
||
assert_eq!(obj.get_raw("Model"), Some("rou_f001_wep_33_hangar"));
|
||
// … while the records disagree with it and with each other.
|
||
for (record, model) in [
|
||
("Designator_LH", "rou_f001_wep_59_hangar"),
|
||
("Laser_Mine_B9L", "rou_f001_wep_29_hangar"),
|
||
("Saber_LG1", "rou_f001_wep_14_hangar"),
|
||
("Chaff_Flare_Dispencer", "rou_f001_wep_71_hangar"),
|
||
] {
|
||
assert_eq!(obj.record(record).unwrap().get("Model"), Some(model));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn dsaber_missile_weapon_stats() {
|
||
skip_without_disc!(root);
|
||
let arc = PakArchive::open(root.join("dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||
// Weapon_DSaber_P_wep_26_Missile
|
||
let bytes = arc
|
||
.read_by_hash(0x4666_5408)
|
||
.expect("entry present")
|
||
.unwrap();
|
||
let obj = IdxdObject::parse(&bytes).unwrap();
|
||
assert_eq!(obj.get_str("TargetType"), Some("Vessel,Craft"));
|
||
assert_eq!(obj.get_i64("LoadingCount"), Some(144));
|
||
assert_eq!(obj.get_f32("Interval"), Some(3.00));
|
||
assert_eq!(obj.get_f32("Mass"), Some(0.77));
|
||
assert_eq!(obj.get_i64("TriggerShotCount"), Some(12));
|
||
assert_eq!(obj.get_f32("MaximumRange"), Some(10000.0));
|
||
}
|
||
|
||
/// The recovered TOC name-hash resolves real entries by their original path.
|
||
#[test]
|
||
fn name_lookup_resolves_weapon_tbl() {
|
||
skip_without_disc!(root);
|
||
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
|
||
|
||
// eng\weapon.tbl is present and hashes to the retail TOC key.
|
||
let entry = arc
|
||
.find_by_name("eng\\weapon.tbl")
|
||
.expect("eng\\weapon.tbl must resolve by name");
|
||
assert_eq!(entry.name_hash, 0x900C_8DCD);
|
||
|
||
// Case-insensitive, matching the game's lowercasing.
|
||
assert_eq!(
|
||
arc.find_by_name("ENG\\Weapon.TBL").map(|e| e.name_hash),
|
||
Some(0x900C_8DCD)
|
||
);
|
||
|
||
// The payload decompresses to a self-describing IDXD table.
|
||
let bytes = arc.read_by_name("eng\\weapon.tbl").unwrap().unwrap();
|
||
assert_eq!(&bytes[..4], b"IDXD");
|
||
let obj = IdxdObject::parse(&bytes).expect("weapon.tbl parses as IDXD");
|
||
assert!(obj.count > 0);
|
||
}
|
||
|
||
/// Real T8aD entries decode to sane RGBA surfaces, and a good fraction of the
|
||
/// pack's T8aD entries are decodable (the RGBA-variant rule holds broadly).
|
||
#[test]
|
||
fn hangar_t8ad_textures_decode() {
|
||
skip_without_disc!(root);
|
||
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
|
||
let (mut seen, mut decoded) = (0usize, 0usize);
|
||
for e in arc.entries() {
|
||
if e.comp_size > 4_000_000 {
|
||
continue;
|
||
}
|
||
let Ok(p) = arc.read(e) else { continue };
|
||
if !sylpheed_formats::t8ad::is_t8ad(&p) {
|
||
continue;
|
||
}
|
||
seen += 1;
|
||
if let Some(img) = sylpheed_formats::t8ad::parse(&p) {
|
||
decoded += 1;
|
||
assert!((1..=4096).contains(&img.width) && (1..=4096).contains(&img.height));
|
||
assert_eq!(img.rgba.len(), (img.width * img.height * 4) as usize);
|
||
}
|
||
}
|
||
assert!(seen > 100, "expected many T8aD, saw {seen}");
|
||
// The RGBA rule should cover the large majority (≈85% disc-wide).
|
||
assert!(decoded * 100 >= seen * 70, "decoded {decoded}/{seen}");
|
||
}
|
||
|
||
/// A real LSTA sprite list yields inline T8aD frames.
|
||
#[test]
|
||
fn lsta_sprite_list_decodes() {
|
||
skip_without_disc!(root);
|
||
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
|
||
let mut found = false;
|
||
for e in arc.entries() {
|
||
let Ok(p) = arc.read(e) else { continue };
|
||
if !sylpheed_formats::lsta::is_lsta(&p) {
|
||
continue;
|
||
}
|
||
if let Some(frames) = sylpheed_formats::lsta::parse(&p) {
|
||
if !frames.is_empty() {
|
||
found = true;
|
||
assert!(frames.iter().all(|f| !f.rgba.is_empty()));
|
||
}
|
||
}
|
||
}
|
||
assert!(found, "no decodable LSTA found");
|
||
}
|
||
|
||
/// A real RATC bundle lists named children including a decodable T8aD.
|
||
#[test]
|
||
fn ratc_bundle_lists_children() {
|
||
skip_without_disc!(root);
|
||
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
|
||
let mut ok = false;
|
||
for e in arc.entries() {
|
||
if e.comp_size > 4_000_000 {
|
||
continue;
|
||
}
|
||
let Ok(p) = arc.read(e) else { continue };
|
||
if !sylpheed_formats::ratc::is_ratc(&p) {
|
||
continue;
|
||
}
|
||
let Some(kids) = sylpheed_formats::ratc::parse(&p) else {
|
||
continue;
|
||
};
|
||
let has_named = kids.iter().any(|c| c.name.contains('.'));
|
||
let has_t8ad = kids.iter().any(|c| {
|
||
c.kind == "T8aD"
|
||
&& sylpheed_formats::t8ad::parse(&p[c.offset..c.offset + c.size]).is_some()
|
||
});
|
||
if !kids.is_empty() && has_named && has_t8ad {
|
||
ok = true;
|
||
break;
|
||
}
|
||
}
|
||
assert!(ok, "no RATC with named children + a decodable T8aD");
|
||
}
|
||
|
||
/// The English movie subtitle track (an `IXUD` entry) decodes to timed cues.
|
||
#[test]
|
||
fn eng_movie_subtitle_track() {
|
||
skip_without_disc!(root);
|
||
let arc = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
|
||
// S04A's track — the longest inline English one (starts "Calm down!").
|
||
let bytes = arc
|
||
.read_by_hash(0x73d2_2a3b)
|
||
.expect("entry present")
|
||
.unwrap();
|
||
let sub = sylpheed_formats::ixud::parse(&bytes).expect("IXUD parses as subtitle");
|
||
assert!(sub.cues.len() > 50, "cues={}", sub.cues.len());
|
||
assert!(!sub.is_reference_only());
|
||
assert_eq!(sub.cues[0].text, "Calm down!");
|
||
assert!((sub.cues[0].start - 9.4).abs() < 0.01);
|
||
assert_eq!(sub.cues[0].end, Some(11.0));
|
||
}
|
||
|
||
/// The subtitle tracks are genuinely localized: the same entry hash differs
|
||
/// between the English and German packs.
|
||
#[test]
|
||
fn subtitles_are_localized() {
|
||
skip_without_disc!(root);
|
||
let eng = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
|
||
let deu = PakArchive::open(root.join("dat/movie/deu.pak")).unwrap();
|
||
let e = eng.read_by_hash(0x73d2_2a3b).unwrap().unwrap();
|
||
let d = deu.read_by_hash(0x73d2_2a3b).unwrap().unwrap();
|
||
assert_ne!(e, d, "eng/deu subtitle payloads should differ");
|
||
}
|
||
|
||
/// The English movie pack's embedded subtitle font parses to sane metadata.
|
||
#[test]
|
||
fn eng_movie_font_metadata() {
|
||
skip_without_disc!(root);
|
||
let arc = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
|
||
let bytes = arc
|
||
.read_by_hash(0x5cd0_fca6)
|
||
.expect("entry present")
|
||
.unwrap();
|
||
assert!(sylpheed_formats::font::is_font(&bytes));
|
||
let info = sylpheed_formats::font::parse_info(&bytes).expect("font parses");
|
||
assert!(info.glyphs > 0, "glyphs={}", info.glyphs);
|
||
assert!(!info.family.is_empty(), "family should be readable");
|
||
}
|
||
|
||
/// The Acheron backdrop is a TXCM world cubemap: 6 faces, and the cube path's
|
||
/// face 0 matches the (validated) 2D `from_xpr2` face-0 decode.
|
||
#[test]
|
||
fn acheron_world_cubemap_six_faces() {
|
||
skip_without_disc!(root);
|
||
let bytes = std::fs::read(root.join("hidden/resource3d/BG_Acheron.xpr")).unwrap();
|
||
|
||
let cube = X360Texture::cube_faces_from_xpr2(&bytes)
|
||
.expect("cube decode ok")
|
||
.expect("BG_Acheron is a TXCM cubemap");
|
||
assert_eq!((cube.width, cube.height), (1024, 1024));
|
||
assert_eq!(cube.faces.len(), 6);
|
||
// A8R8G8B8 → 4 bytes/texel, de-tiled to width×height.
|
||
for face in &cube.faces {
|
||
assert_eq!(face.len(), 1024 * 1024 * 4);
|
||
}
|
||
// Face 0 must equal what the existing 2D path decodes (the green planet).
|
||
let face0_2d = X360Texture::from_xpr2(&bytes).unwrap();
|
||
assert!(face0_2d.is_cubemap);
|
||
assert_eq!(cube.faces[0], face0_2d.data);
|
||
}
|