game_data: Squadron loader — mission flight groups

Decode the per-stage Enumerate_Squadrons tables (UnitGroup_S<NN>.tbl)
into flight groups: formation shape, AI behaviour, side, and the member
craft. Definitions are delimited by the FormationID key; members are the
first `count` UN_ craft after the header (anything past them belongs to
the next squadron or the stage roster — the naive "all UN_ in span"
over-collected). e.g. the player's Rhino flight = a 2-craft TCAF
squadron of Delta Sabers flying Formation_2_Rhino. Schema hash varies
per stage so we match on the table name. Squadron id is positional and
left None when the id list and definitions don't line up (honest, not
guessed). +1 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-23 19:54:48 +02:00
parent d73601fe45
commit 2930770060
2 changed files with 107 additions and 0 deletions

View File

@@ -436,6 +436,84 @@ pub fn load_stages(pak: &PakArchive) -> Vec<Stage> {
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<NN>.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<String>,
pub formation_id: Option<String>,
pub ai_id: Option<String>,
/// Faction — `TCAF` (allied) or `ADAN` (enemy).
pub side: Option<String>,
pub count: Option<i64>,
/// Member craft (`UN_*` unit ids).
pub members: Vec<String>,
}
/// 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<Squadron> {
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<usize> = 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<String> {
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::<i64>().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 };