Files
Sylpheed/crates/sylpheed-formats/tests/pak_idxd_disc.rs
Sylpheed RE agent 723fc9b890 re: audit the legacy IDXD reader against the real field table — and fix a test that encoded its error
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
2026-08-25 21:42:51 +00:00

308 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}