Files
Syplheed-Reborn/crates/sylpheed-formats/tests/pak_idxd_disc.rs
MechaCat02 ac340b9219 feat(formats): recover IPFB TOC name-hash; look up entries by path
The pak TOC key was previously an unknown hash (crc32/fnv/djb2/… ruled
out), forcing content-only lookup. Recovered it by static RE of the
retail title:

  sub_824609C8 (pak lookup-by-name) dups the requested path, lowercases
  it (sub_825F4F90), hashes with sub_82455C78, then binary-searches the
  sorted TOC. sub_82455C78 is a per-byte Barrett-reduced polynomial hash
  (modulus 0x00FFF9D7, reciprocal 0x80031493): the low 24 bits are the
  modular hash, the top byte an 8-bit additive checksum of the bytes.

New `hash` module reproduces it exactly (faithful op sequence, no
textbook %). Verified against the real disc: name_hash("files.tbl") ==
0x83421153 and name_hash("eng\\weapon.tbl") == 0x900C8DCD, both present
in retail TOCs. Add PakArchive::find_by_name / read_by_name and a disc
integration test resolving eng\weapon.tbl by path (→ valid IDXD).

Note: retail paths use backslash separators (eng\weapon.tbl).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:27:07 +02:00

125 lines
4.8 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::{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));
// a defaulted/omitted field must be None, not a neighbouring key
assert_eq!(obj.get_f32("ShieldRatio"), None);
}
#[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);
}