diff --git a/crates/sylpheed-formats/examples/squadrons.rs b/crates/sylpheed-formats/examples/squadrons.rs new file mode 100644 index 0000000..44baaf2 --- /dev/null +++ b/crates/sylpheed-formats/examples/squadrons.rs @@ -0,0 +1,11 @@ +use sylpheed_formats::{game_data, 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 sq=game_data::load_squadrons(&pak); + println!("{} squadron definitions", sq.len()); + for s in sq.iter().filter(|s|s.side.as_deref()==Some("TCAF")).take(6){ + let mem:Vec=s.members.iter().map(|m|m.trim_start_matches("UN_").chars().take(18).collect()).collect(); + println!(" {:8} {:22} {:24} x{} {:?}", s.id.clone().unwrap_or("?".into()), s.formation_id.clone().unwrap_or_default(), s.ai_id.clone().unwrap_or_default(), s.count.unwrap_or(0), mem); + } +} diff --git a/crates/sylpheed-formats/src/game_data.rs b/crates/sylpheed-formats/src/game_data.rs index 2f2ded4..958b5c1 100644 --- a/crates/sylpheed-formats/src/game_data.rs +++ b/crates/sylpheed-formats/src/game_data.rs @@ -436,6 +436,84 @@ pub fn load_stages(pak: &PakArchive) -> Vec { load_table(pak, schema::STAGE, Stage::from_idxd) } +// ── Squadron / flight group ───────────────────────────────────────────────────── + +/// A squadron — a flight group that spawns together: its formation shape, AI +/// behaviour, side, and member craft (with their pilots). Parsed from the +/// per-stage `Enumerate_Squadrons` tables (`UnitGroup_S.tbl`). The player's +/// Rhino flight, for instance, is a 2-craft TCAF squadron flying `Formation_2_Rhino`. +#[derive(Debug, Clone)] +pub struct Squadron { + /// Squadron tag (`TCN001`, `ADT101`, …), matched positionally to its + /// definition; `None` if the id list and definitions don't line up. + pub id: Option, + pub formation_id: Option, + pub ai_id: Option, + /// Faction — `TCAF` (allied) or `ADAN` (enemy). + pub side: Option, + pub count: Option, + /// Member craft (`UN_*` unit ids). + pub members: Vec, +} + +/// Load every squadron from a pak. Scans all `Enumerate_Squadrons` tables (their +/// schema hash varies per stage, but the table name is stable). +pub fn load_squadrons(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(); + if !t.first().is_some_and(|s| s.contains("Enumerate_Squadron")) { + continue; + } + // Definitions are delimited by the `FormationID` key (its value, the + // formation, sits just before it). The id list precedes the first one. + let fpos: Vec = t + .iter() + .enumerate() + .filter(|(_, s)| *s == "FormationID") + .map(|(i, _)| i) + .collect(); + let Some(&first) = fpos.first() else { continue }; + // The squadron id list: tokens after the header up to the first formation. + let ids: Vec<&String> = t[1..first.saturating_sub(1)].iter().collect(); + for (k, &fi) in fpos.iter().enumerate() { + let start = fi.saturating_sub(1); + let end = fpos.get(k + 1).map(|&n| n.saturating_sub(1)).unwrap_or(t.len()); + let span = &t[start..end]; + let before = |key: &str| -> Option { + span.iter().position(|s| s == key).and_then(|i| (i > 0).then(|| span[i - 1].clone())) + }; + let count = before("Count").and_then(|v| v.parse::().ok()); + // Members are the first `count` `UN_` craft after the header block + // (which ends at DisableInterval, else Count); anything past them + // belongs to the next squadron or the stage roster. + let member_start = span + .iter() + .position(|s| s == "DisableInterval") + .or_else(|| span.iter().position(|s| s == "Count")) + .map(|i| i + 1) + .unwrap_or(0); + let take = count.unwrap_or(0).max(0) as usize; + out.push(Squadron { + id: (ids.len() == fpos.len()).then(|| ids[k].clone()), + formation_id: Some(t[fi - 1].clone()), + ai_id: before("AIID"), + side: before("SideID"), + count, + members: span[member_start..] + .iter() + .filter(|s| s.starts_with("UN_")) + .take(take) + .cloned() + .collect(), + }); + } + } + out +} + // ── Generic record access (any of the ~105 schemas) ───────────────────────────── /// A generically-decoded IDXD record: its table name, schema id, identity, and @@ -572,6 +650,24 @@ mod tests { } } + #[test] + fn loads_squadrons() { + let Some(pak) = pak() else { return }; + let sq = load_squadrons(&pak); + assert!(sq.len() >= 20, "expected many squadrons, got {}", sq.len()); + // A TCAF Rhino flight of Delta Sabers, membership matching its count. + let rhino = sq + .iter() + .find(|s| { + s.side.as_deref() == Some("TCAF") + && s.formation_id.as_deref().is_some_and(|f| f.contains("Rhino")) + && s.members.iter().any(|m| m.contains("DeltaSaber")) + }) + .expect("a TCAF Rhino Delta Saber squadron"); + assert_eq!(rhino.members.len() as i64, rhino.count.unwrap_or(-1)); + assert!(rhino.members.iter().all(|m| m.contains("DeltaSaber"))); + } + #[test] fn generic_records_read_any_schema() { let Some(pak) = pak() else { return };