From 551291f869d8b9bed63b4561f05dfdd20dc5b528 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 23 Jul 2026 20:11:32 +0200 Subject: [PATCH] =?UTF-8?q?game=5Fdata:=20UnitRoster=20loader=20=E2=80=94?= =?UTF-8?q?=20per-mission=20battle=20compositions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decode the EnumUnit tables (schema 35b8dc67) into mission rosters: the distinct combatant `UN_*` ids for one battle, stage props (asteroids, collision meshes) dropped. The tables aren't tagged with their stage on disc, so `stage` is inferred from any `UN_S_…` prop id in the roster and left None otherwise (honest — ~8 of 31 rosters self-identify). Joined against load_units / load_vessels this gives each battle's combatants with stats, e.g. S01 = ADAN Turret (100 HP), Attacker (500 HP), Destroyer (10000 HP). Example `battle` prints them. Co-Authored-By: Claude Opus 4.8 --- crates/sylpheed-formats/examples/battle.rs | 19 +++++++ crates/sylpheed-formats/src/game_data.rs | 65 ++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 crates/sylpheed-formats/examples/battle.rs 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 };