diff --git a/crates/sylpheed-formats/examples/battle.rs b/crates/sylpheed-formats/examples/battle.rs new file mode 100644 index 0000000..be624ba --- /dev/null +++ b/crates/sylpheed-formats/examples/battle.rs @@ -0,0 +1,19 @@ +use sylpheed_formats::{game_data as gd, PakArchive}; +fn main(){ + let disc=std::env::var("SYLPHEED_DISC").unwrap(); + let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap(); + let units=gd::load_units(&pak); let vessels=gd::load_vessels(&pak); + let hp=|id:&str|->String{ + units.iter().find(|u|u.id.as_deref()==Some(id)).and_then(|u|u.hp).map(|h|format!("{h:.0}hp")) + .or_else(||vessels.iter().find(|v|v.id.as_deref()==Some(id)).and_then(|v|v.hp).map(|h|format!("{h:.0}HP"))) + .unwrap_or("·".into()) + }; + let rosters=gd::load_unit_rosters(&pak); + for r in rosters.iter().filter(|r|r.stage.is_some()).take(4){ + println!("\n{} — {} combatants:", r.stage.as_deref().unwrap(), r.units.len()); + for u in r.units.iter().filter(|u|u.contains("ADAN")).take(6){ + let short=u.trim_start_matches("UN_").split('_').skip(1).collect::>().join("_"); + println!(" {short:32} {}", hp(u)); + } + } +} diff --git a/crates/sylpheed-formats/src/game_data.rs b/crates/sylpheed-formats/src/game_data.rs index b6ce3ab..77f7f40 100644 --- a/crates/sylpheed-formats/src/game_data.rs +++ b/crates/sylpheed-formats/src/game_data.rs @@ -573,6 +573,56 @@ pub fn load_demo_messages(pak: &PakArchive) -> Vec { out } +// ── Unit roster (per-mission combatants) ──────────────────────────────────────── + +/// Schema of the per-stage `EnumUnit` roster tables. +const ENUM_UNIT_SCHEMA: u32 = 0x35b8_dc67; + +/// A mission's unit roster — the craft, vessels and props that can appear in one +/// battle (an `EnumUnit_S` table). Join the ids against [`load_units`] / +/// [`load_vessels`] for stats. The table isn't tagged with its stage on disc, so +/// [`stage`](UnitRoster::stage) is inferred from any `S_`-prefixed prop id in +/// the roster (asteroid collision meshes etc.) and is `None` when none is present. +#[derive(Debug, Clone)] +pub struct UnitRoster { + pub stage: Option, + /// Distinct combatant unit ids (`UN_*`), excluding stage props (asteroids, + /// collision meshes). + pub units: Vec, +} + +/// Load every mission unit roster from a pak. +pub fn load_unit_rosters(pak: &PakArchive) -> Vec { + let mut out = Vec::new(); + for e in pak.entries() { + let Ok(b) = pak.read(e) else { continue }; + let Ok(o) = IdxdObject::parse(&b) else { continue }; + if o.schema_hash != ENUM_UNIT_SCHEMA { + continue; + } + let ids: Vec<&str> = o.tokens().iter().filter(|s| s.starts_with("UN_")).map(String::as_str).collect(); + // Stage tag from an `UN_S_…` prop id (e.g. UN_S01_Asteroid_cmesh_…). + let stage = ids.iter().find_map(|s| { + let r = s.trim_start_matches("UN_"); + let bytes = r.as_bytes(); + (r.len() >= 3 && bytes[0] == b'S' && bytes[1].is_ascii_digit() && bytes[2].is_ascii_digit()) + .then(|| r[..3].to_string()) + }); + // Combatants: drop the stage props, dedup preserving order. + let mut units = Vec::new(); + for id in ids { + let prop = id.contains("Asteroid") || id.contains("cmesh") || id.contains("_Box"); + if !prop && !units.iter().any(|u| u == id) { + units.push(id.to_string()); + } + } + if !units.is_empty() { + out.push(UnitRoster { stage, units }); + } + } + out +} + // ── Generic record access (any of the ~105 schemas) ───────────────────────────── /// A generically-decoded IDXD record: its table name, schema id, identity, and @@ -743,6 +793,21 @@ mod tests { assert!(m.page_keys.iter().all(|k| k.starts_with(&m.id))); } + #[test] + fn loads_unit_rosters() { + let Some(pak) = pak() else { return }; + let rosters = load_unit_rosters(&pak); + assert!(rosters.len() > 20, "expected many rosters, got {}", rosters.len()); + // At least a few tag their stage; all rosters carry combatants, no props. + assert!(rosters.iter().filter(|r| r.stage.is_some()).count() >= 4); + assert!(rosters.iter().all(|r| { + !r.units.is_empty() && r.units.iter().all(|u| !u.contains("Asteroid")) + })); + // A roster tagged S01 exists and names the player's Delta Saber. + let s01 = rosters.iter().find(|r| r.stage.as_deref() == Some("S01")); + assert!(s01.is_some_and(|r| r.units.iter().any(|u| u.contains("DeltaSaber")))); + } + #[test] fn generic_records_read_any_schema() { let Some(pak) = pak() else { return };