With the record table decoded there is finally a ground truth to check the
old string-pool reader against. It infers `key -> value` from pool adjacency,
which is a consequence of how records are written, not a rule of the format.
Verified by hand against the disc, with an independent parser:
* `FCSRange` = 500000.0 — the module docs' own canonical example of a field
"left at its default" that "omits the value string".
* `ShieldRatio` = 1.0, where `tests/pak_idxd_disc.rs` asserted None with the
comment "a defaulted/omitted field must be None". That test encoded the
false belief; it now keeps the None as a deliberate characterisation of the
legacy reader, with the true value asserted beside it.
* `get_raw("Model")` on GP_HANGAR_ARSENAL returns the first record's model for
every record — silent corruption, not an absent value. New test pins four
records that disagree with it.
The cause is the flat API having no way to name a record: only 548 of 6325
objects have one. `HP` on the DeltaSaber answers 1000.0, the hull, while 63
Turret_* records each carry their own 100.0 (measured — a first draft said 34,
taken from a report rather than from the disc).
Disc-wide rates are recorded as single-source and labelled as such: get_raw
52% wrong, typed getters 38% miss, but 100% correct on single-record objects.
Also records a negative result: the 504 unnamed field keys were NOT recovered.
A 572464-string dictionary and 73191 variants gave 0/42. The key deltas do
prove the preimage ends with the two decimal digits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
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 via
|
||
//! the `SYLPHEED_DISC` env var, or drop it at the default dev path below.
|
||
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use sylpheed_formats::texture::X360Texture;
|
||
use sylpheed_formats::{IdxdObject, PakArchive};
|
||
|
||
/// Locate the extracted disc root, or `None` to skip.
|
||
fn disc_root() -> Option<PathBuf> {
|
||
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
|
||
let p = PathBuf::from(p);
|
||
if p.join("dat").is_dir() {
|
||
return Some(p);
|
||
}
|
||
}
|
||
let default = Path::new(
|
||
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)",
|
||
);
|
||
if default.join("dat").is_dir() {
|
||
return Some(default.to_path_buf());
|
||
}
|
||
None
|
||
}
|
||
|
||
macro_rules! skip_without_disc {
|
||
($root:ident) => {
|
||
let Some($root) = disc_root() else {
|
||
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
|
||
return;
|
||
};
|
||
};
|
||
}
|
||
|
||
#[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);
|
||
}
|