feat(formats): IPFB archive + IDXD definition readers
Add readers for Project Sylpheed's on-disc data format so the reimplementation can load exact ship/weapon constants straight from the game files. - pak.rs: PakArchive — parse the IPFB `.pak` index + concatenated `.pNN` segments, binary-search the TOC by name-hash, and transparently inflate the per-entry "Z1" (zlib) container. Adds flate2. - idxd.rs: IdxdObject — parse the IDXD reflective object serialization. The string pool stores properties value-before-key with defaulted fields omitted; typed getters (get_f32/get_i64/get_str/get_bool) read explicit values reliably, get_raw exposes identifier fields, and resolved_fields() enumerates every explicit (key,value). Defaulted fields correctly return None rather than a neighbouring key. - sylpheed-cli: `pak list` (inventory entries + identity) and `pak dump <hash>` (full stat sheet for one object). Verified against the real disc (auto-skipped without it): DeltaSaber craft (HP=1000, Acceleration=600, velocity curve 100/700/1200, Turn=100, RadarRange=500000) and the DSaber missile (LoadingCount=144, Interval=3.00, Mass=0.77). 10 unit + 3 integration tests pass. Not yet decoded: defaulted fields (many ratios, the *Count family) whose values come from schema defaults or the hash-keyed binary node region. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
99
crates/sylpheed-formats/tests/pak_idxd_disc.rs
Normal file
99
crates/sylpheed-formats/tests/pak_idxd_disc.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
//! 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));
|
||||
}
|
||||
Reference in New Issue
Block a user