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
|
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) ─────────────────────────────
|
// ── Generic record access (any of the ~105 schemas) ─────────────────────────────
|
||||||
|
|
||||||
/// A generically-decoded IDXD record: its table name, schema id, identity, and
|
/// 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)));
|
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]
|
#[test]
|
||||||
fn generic_records_read_any_schema() {
|
fn generic_records_read_any_schema() {
|
||||||
let Some(pak) = pak() else { return };
|
let Some(pak) = pak() else { return };
|
||||||
|
|||||||
Reference in New Issue
Block a user