diff --git a/crates/sylpheed-formats/examples/flights.rs b/crates/sylpheed-formats/examples/flights.rs new file mode 100644 index 0000000..b4390f9 --- /dev/null +++ b/crates/sylpheed-formats/examples/flights.rs @@ -0,0 +1,17 @@ +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_HANGAR_ARSENAL.pak")).unwrap(); + let rosters=gd::load_pilot_rosters(&pak); + // distinct rosters by their pilot set + let mut seen=std::collections::BTreeSet::new(); let mut shown=0; + println!("{} pilot-roster configs; distinct line-ups:", rosters.len()); + for r in &rosters{ + let key:String=r.pilots.iter().map(|(c,p)|format!("{c}:{p}")).collect::>().join(","); + if seen.insert(key) && shown<8 { + shown+=1; + let flt:Vec=r.pilots.iter().map(|(c,p)|format!("{c}={p}")).collect(); + println!(" {}", flt.join(" ")); + } + } +} diff --git a/crates/sylpheed-formats/src/game_data.rs b/crates/sylpheed-formats/src/game_data.rs index 77f7f40..3cdc95c 100644 --- a/crates/sylpheed-formats/src/game_data.rs +++ b/crates/sylpheed-formats/src/game_data.rs @@ -623,6 +623,54 @@ pub fn load_unit_rosters(pak: &PakArchive) -> Vec { out } +// ── Pilot roster (player squadron assignments) ────────────────────────────────── + +/// The player squadron's flight assignments for a mission — which pilot flies each +/// callsign (`Rhino1` → `Katana`, `Bird1` → `Sandra`, …). Parsed from the +/// `UNITS`/`ZUNITS` player-unit tables (in `GP_HANGAR_ARSENAL.pak`); assignments +/// vary per mission (different missions field different wingmen). +#[derive(Debug, Clone)] +pub struct PilotRoster { + /// `(callsign, pilot)` pairs in flight order, e.g. `("Rhino1", "Katana")`. + pub pilots: Vec<(String, String)>, + /// The player craft unit id (`UN_f002_TCAF_DeltaSaber…`). + pub player_unit: Option, +} + +/// True for a `Callsign-Pilot` token (a single hyphen, digit-suffixed callsign, +/// alphabetic pilot) — the flight-assignment shape. +fn is_assignment(s: &str) -> bool { + let Some((cs, pilot)) = s.split_once('-') else { return false }; + !pilot.is_empty() + && pilot.chars().all(|c| c.is_ascii_alphabetic()) + && cs.len() >= 2 + && cs.as_bytes()[cs.len() - 1].is_ascii_digit() + && cs.chars().all(|c| c.is_ascii_alphanumeric()) +} + +/// Load every player pilot roster from a pak (scan `GP_HANGAR_ARSENAL.pak`). One +/// per player-unit config; rosters with no assignment tokens are skipped. +pub fn load_pilot_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 }; + let t = o.tokens(); + let pilots: Vec<(String, String)> = t + .iter() + .filter(|s| is_assignment(s)) + .filter_map(|s| s.split_once('-').map(|(c, p)| (c.to_string(), p.to_string()))) + .collect(); + if pilots.len() >= 2 { + out.push(PilotRoster { + pilots, + player_unit: t.iter().find(|s| s.starts_with("UN_")).cloned(), + }); + } + } + out +} + // ── Generic record access (any of the ~105 schemas) ───────────────────────────── /// A generically-decoded IDXD record: its table name, schema id, identity, and @@ -808,6 +856,20 @@ mod tests { assert!(s01.is_some_and(|r| r.units.iter().any(|u| u.contains("DeltaSaber")))); } + #[test] + fn loads_pilot_rosters() { + let Ok(disc) = std::env::var("SYLPHEED_DISC") else { return }; + let Ok(pak) = PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")) else { return }; + let rosters = load_pilot_rosters(&pak); + assert!(!rosters.is_empty(), "expected player pilot rosters"); + // Some config assigns Katana to a Rhino callsign (she leads Rhino flight). + assert!(rosters.iter().any(|r| { + r.pilots.iter().any(|(cs, p)| cs.starts_with("Rhino") && p == "Katana") + })); + // Bird flight exists too. + assert!(rosters.iter().any(|r| r.pilots.iter().any(|(cs, _)| cs.starts_with("Bird")))); + } + #[test] fn generic_records_read_any_schema() { let Some(pak) = pak() else { return };