game_data: UnitRoster loader — per-mission battle compositions
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<NN>_…` 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 <noreply@anthropic.com>
This commit is contained in:
19
crates/sylpheed-formats/examples/battle.rs
Normal file
19
crates/sylpheed-formats/examples/battle.rs
Normal file
@@ -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::<Vec<_>>().join("_");
|
||||
println!(" {short:32} {}", hp(u));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -573,6 +573,56 @@ pub fn load_demo_messages(pak: &PakArchive) -> Vec<DemoMessage> {
|
||||
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<NN>` 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<NN>_`-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<String>,
|
||||
/// Distinct combatant unit ids (`UN_*`), excluding stage props (asteroids,
|
||||
/// collision meshes).
|
||||
pub units: Vec<String>,
|
||||
}
|
||||
|
||||
/// Load every mission unit roster from a pak.
|
||||
pub fn load_unit_rosters(pak: &PakArchive) -> Vec<UnitRoster> {
|
||||
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<NN>_…` 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 };
|
||||
|
||||
Reference in New Issue
Block a user