diff --git a/crates/sylpheed-formats/examples/flights.rs b/crates/sylpheed-formats/examples/flights.rs index b4390f9..c806d36 100644 --- a/crates/sylpheed-formats/examples/flights.rs +++ b/crates/sylpheed-formats/examples/flights.rs @@ -7,10 +7,10 @@ fn main(){ let mut seen=std::collections::BTreeSet::new(); let mut shown=0; println!("{} pilot-roster configs; distinct line-ups:", rosters.len()); for r in &rosters{ - let key:String=r.pilots.iter().map(|(c,p)|format!("{c}:{p}")).collect::>().join(","); + let key:String=r.pilots().iter().map(|(c,p)|format!("{c}:{p}")).collect::>().join(","); if seen.insert(key) && shown<8 { shown+=1; - let flt:Vec=r.pilots.iter().map(|(c,p)|format!("{c}={p}")).collect(); + let flt:Vec=r.pilots().iter().map(|(c,p)|format!("{c}={p}")).collect(); println!(" {}", flt.join(" ")); } } diff --git a/crates/sylpheed-formats/examples/squadrons.rs b/crates/sylpheed-formats/examples/squadrons.rs index 44baaf2..c11a10c 100644 --- a/crates/sylpheed-formats/examples/squadrons.rs +++ b/crates/sylpheed-formats/examples/squadrons.rs @@ -5,7 +5,7 @@ fn main(){ 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); + let mem:Vec=s.members.iter().map(|m|format!("{}/{}",m.unit.trim_start_matches("UN_").chars().take(18).collect::(),m.pilot.clone().unwrap_or("·".into()))).collect(); + println!(" {:8} {:22} {:24} x{} {:?}", s.id.clone(), 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 47c6971..08dcbba 100644 --- a/crates/sylpheed-formats/src/game_data.rs +++ b/crates/sylpheed-formats/src/game_data.rs @@ -1,15 +1,35 @@ //! Typed loaders for Project Sylpheed's combat data tables. //! //! The game keeps its balance data in reflective [`crate::idxd`] tables — one -//! blob per entity, `token[0]` naming the table, fields laid out value-before-key. -//! This module maps the four combat schemas to plain, cloneable structs: the -//! common stats as named `Option<…>` fields, and **every** explicitly-set field in -//! a [`fields`](Weapon::fields) map so nothing is lost. +//! blob per entity. Each blob is an **array of named records**, and each record +//! owns a set of named or positional fields: `Generic` holds a craft's identity +//! and hull, `Maneuver` its flight model, `StructureCount` its component counts, +//! and one `Turret_NNN` / `Bridge_NNN` / `Thruster_NNN` / `Hatch_NNN` / +//! `ShieldGenerator_NNN` record per destructible hardpoint. //! -//! Reverse-engineered 2026-07-23 from `GP_MAIN_GAME_E.pak` (English; the D/F/I/J/S -//! paks are localized duplicates). Fields left at their default value omit their -//! value on disc — those live in title code, not here — so a `None` means "not set -//! on disc", never a guess. +//! ## What changed (2026-08-25): read the record table, not the string pool +//! +//! This module used to read fields through the legacy string-pool reader +//! ([`IdxdObject::get_raw`] and [`resolved_fields`](IdxdObject::resolved_fields)), +//! which infers a value from *pool adjacency* and has no way to say **which +//! record** it means. Measured over `GP_MAIN_GAME_E.pak`, of the 4435 reads the +//! six struct loaders performed, 2872 agreed with the record table, **966 +//! returned `None` for a field that has a value**, **596 flattened a field that +//! several records carry** (one answer for up to 63 turrets), and 1 was simply +//! wrong. Every read here now goes through [`IdxdObject::record`], and the types +//! say which record each value comes from: +//! +//! * a genuinely per-record stat is a per-record value in the API — +//! [`Hardpoint`] (per turret/bridge/thruster), [`PlayerPhase`] (per mission +//! phase), [`ScoreRules`] (per difficulty), [`StagePhase`], [`SquadronMember`]; +//! * the `fields` map of every struct became [`RecordSet`], which keeps the +//! record boundary instead of merging records into one map. +//! +//! Reverse-engineered from `GP_MAIN_GAME_E.pak` (English; the D/F/I/J/S paks are +//! localized duplicates) and `GP_HANGAR_ARSENAL.pak`. A `None` means the field is +//! absent from that record on disc — never a guess. An empty string on disc is +//! reported as `None` by the typed accessors and as `Some("")` by +//! [`RecordSet::get`], which returns exactly what is stored. //! //! ```no_run //! use sylpheed_formats::{game_data, PakArchive}; @@ -23,147 +43,460 @@ use crate::idxd::IdxdObject; use crate::pak::PakArchive; use std::collections::BTreeMap; -/// IDXD schema ids of the combat tables (the `schema_hash` field of each blob). +/// Record-0 name hashes of the combat tables — what [`IdxdObject::schema_hash`] +/// holds. It is **not** a schema id: it is `tag_hash` of the object's +/// lowest-hashed record name, which happens to be stable per table kind +/// (`PLAYER` → `Difficulty_Easy`, `UNIT` → `Maneuver`, `VESSEL` → `Bridge_000`, +/// `MESSAGE` → `Message_000`). Verified over `GP_MAIN_GAME_E.pak`: every object +/// of each constant below has exactly that record 0. pub mod schema { /// Player craft config: physics, cameras, scoring, difficulty, render pipeline. + /// Record 0 = `Difficulty_Easy`. pub const PLAYER: u32 = 0x0426_e81d; - /// Weapon definitions (guns, missiles, beams, bombs). + /// Weapon definitions (guns, missiles, beams, bombs). Record 0 = `Weapon`. + /// Prefer [`super::load_weapons`], which selects on the records themselves and + /// so also catches the 10 objects whose record 0 is a missile-parameter block. pub const WEAPON: u32 = 0x6ab4_825a; - /// Craft / units — fighters, bombers, turrets, with their AI flight model. + /// Craft / units — with their AI flight model. Record 0 = `Maneuver`. pub const UNIT: u32 = 0x43fa_a517; /// Vessels — capital ships, with structural component counts. + /// Record 0 = `Bridge_000`. pub const VESSEL: u32 = 0x3c5b_0549; /// Characters — pilots / crew / comms, with faction and portrait set. + /// Record 0 = `Generic`. pub const CHARACTER: u32 = 0xbd86_d41c; /// Stage resource manifest — per mission: location, phases, resource tables. + /// Record 0 = `StageResource`. pub const STAGE: u32 = 0x3c9a_e32e; /// Message / demo dialogue — per line: speaker, portrait, voice clip, pages. + /// Record 0 = `Message_000`. pub const MESSAGE: u32 = 0xb412_e6d8; } +// ── Record access ───────────────────────────────────────────────────────────── + /// Parse a numeric field value, tolerating a trailing `f`/`F` (e.g. `"3.0f"`). -fn as_f32(v: Option<&String>) -> Option { +fn as_f32(v: Option<&str>) -> Option { let v = v?; - let v = v.strip_suffix(['f', 'F']).unwrap_or(v); - v.parse().ok() + v.strip_suffix(['f', 'F']).unwrap_or(v).parse().ok() } -fn as_i64(v: Option<&String>) -> Option { +fn as_i64(v: Option<&str>) -> Option { v?.parse().ok() } -/// Collect every explicitly-set field of an IDXD blob into a `key → value` map, -/// plus the identity strings. Identifier-valued fields (`Model`, `ShotType`, …) -/// that carry a bare name rather than a value are not in the map — read those from -/// [`IdxdObject::get_raw`] if needed. -fn field_map(o: &IdxdObject) -> BTreeMap { - o.resolved_fields() - .into_iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() +fn as_bool(v: Option<&str>) -> Option { + match v? { + "Yes" | "YES" | "On" | "ON" | "1" => Some(true), + "No" | "NO" | "Off" | "OFF" | "0" => Some(false), + _ => None, + } } -/// Read every record of `schema` from a pak, mapping each with `build`. -fn load_table(pak: &PakArchive, schema: u32, build: impl Fn(&IdxdObject) -> Option) -> Vec { +/// A stored string, with the empty string — which the tables use for "no value +/// here" in identifier-valued fields — reported as `None`. +fn text(v: Option<&str>) -> Option { + v.filter(|s| !s.is_empty()).map(str::to_string) +} + +/// One record's fields: the named ones by name, the positional ones by index. +/// +/// A field is positional exactly when it stores no name on disc; its key is then +/// a literal integer (a member slot, a page line, a weapon-option index). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RecordFields { + /// `field name → value`, exactly as stored (an empty value stays empty). + pub named: BTreeMap, + /// `(index, value)` for the unnamed fields, ascending by index. + pub positional: Vec<(u32, String)>, +} + +impl RecordFields { + /// The stored value of `field`, empty string included. + pub fn get(&self, field: &str) -> Option<&str> { + self.named.get(field).map(String::as_str) + } + /// `field` as a string, treating an empty stored value as absent. + pub fn text(&self, field: &str) -> Option { + text(self.get(field)) + } + pub fn f32(&self, field: &str) -> Option { + as_f32(self.get(field)) + } + pub fn i64(&self, field: &str) -> Option { + as_i64(self.get(field)) + } + pub fn bool(&self, field: &str) -> Option { + as_bool(self.get(field)) + } + /// The positional field stored at `index`. + pub fn at(&self, index: u32) -> Option<&str> { + self.positional + .iter() + .find(|(i, _)| *i == index) + .map(|(_, v)| v.as_str()) + } + /// The positional values from `index` upwards, in index order. + pub fn slots_from(&self, index: u32) -> impl Iterator { + self.positional + .iter() + .filter(move |(i, _)| *i >= index) + .map(|(_, v)| v.as_str()) + } +} + +/// Every record of one IDXD object, keyed by record name. +/// +/// This replaces the flat `fields` map the structs used to carry. That map was +/// built by merging every record of the object into one namespace, so a field +/// several records define (`HP` on 63 `Turret_*` records, `MainMissionBonus` on +/// three `Score_*` records) collapsed to a single arbitrary answer. Here the +/// record boundary survives, and [`everywhere`](Self::everywhere) enumerates the +/// per-record answers. +#[derive(Debug, Clone, Default)] +pub struct RecordSet { + by_name: BTreeMap, + /// Record names in on-disc (name-hash-ascending) order. + order: Vec, +} + +impl RecordSet { + fn from_idxd(o: &IdxdObject) -> Self { + let mut set = RecordSet::default(); + for r in o.records().unwrap_or(&[]) { + let mut f = RecordFields::default(); + for field in &r.fields { + match &field.name { + Some(n) => { + f.named.insert(n.clone(), field.value.clone()); + } + None => f.positional.push((field.key, field.value.clone())), + } + } + f.positional.sort_by_key(|(i, _)| *i); + set.order.push(r.name.clone()); + set.by_name.insert(r.name.clone(), f); + } + set + } + + /// The record named `name`. + pub fn record(&self, name: &str) -> Option<&RecordFields> { + self.by_name.get(name) + } + /// `record`'s `field`, exactly as stored. + pub fn get(&self, record: &str, field: &str) -> Option<&str> { + self.record(record)?.get(field) + } + /// `record`'s `field` as a string, treating an empty stored value as absent. + pub fn text(&self, record: &str, field: &str) -> Option { + self.record(record)?.text(field) + } + pub fn f32(&self, record: &str, field: &str) -> Option { + self.record(record)?.f32(field) + } + pub fn i64(&self, record: &str, field: &str) -> Option { + self.record(record)?.i64(field) + } + pub fn bool(&self, record: &str, field: &str) -> Option { + self.record(record)?.bool(field) + } + /// Record names, in on-disc order. + pub fn names(&self) -> impl Iterator { + self.order.iter().map(String::as_str) + } + /// Record 0's name — the one whose hash the header word carries. + pub fn first_name(&self) -> Option<&str> { + self.order.first().map(String::as_str) + } + /// Every `(record, value)` that defines `field`, in on-disc record order. + /// This is the honest answer the old flat map could not give. + pub fn everywhere(&self, field: &str) -> Vec<(&str, &str)> { + self.order + .iter() + .filter_map(|n| Some((n.as_str(), self.by_name.get(n)?.get(field)?))) + .collect() + } + /// Records whose name starts with `prefix`, in on-disc order. + pub fn starting_with<'a>( + &'a self, + prefix: &'a str, + ) -> impl Iterator { + self.order + .iter() + .filter(move |n| n.starts_with(prefix)) + .filter_map(move |n| Some((n.as_str(), self.by_name.get(n)?))) + } + pub fn len(&self) -> usize { + self.order.len() + } + pub fn is_empty(&self) -> bool { + self.order.is_empty() + } +} + +/// Read every object of a pak that `keep` accepts, mapping each with `build`. +fn load_objects( + pak: &PakArchive, + keep: impl Fn(&IdxdObject) -> bool, + build: impl Fn(&IdxdObject) -> Option, +) -> Vec { pak.entries() .iter() .filter_map(|e| pak.read(e).ok()) .filter_map(|b| IdxdObject::parse(&b).ok()) - .filter(|o| o.schema_hash == schema) + .filter(|o| keep(o)) .filter_map(|o| build(&o)) .collect() } +/// Read every object whose record 0 hashes to `schema` (see [`schema`]). +fn load_table(pak: &PakArchive, schema: u32, build: impl Fn(&IdxdObject) -> Option) -> Vec { + load_objects(pak, |o| o.schema_hash == schema, build) +} + +// ── Hardpoints (per-record ship components) ───────────────────────────────────── + +/// The kind of a destructible ship component, from its record-name prefix. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum HardpointKind { + /// `Bridge_NNN` — command deck. + Bridge, + /// `Hatch_NNN` — launch bay (carries a `SquadronID` to scramble). + Hatch, + /// `ShieldGenerator_NNN` — shield emitter (`PowerRatio` of the total). + ShieldGenerator, + /// `Thruster_NNN` — engine block. + Thruster, + /// `Turret_NNN` — gun mount, or on the player's craft an equippable weapon + /// slot (`rou_f001_wep_NN`). + Turret, +} + +impl HardpointKind { + /// The record-name prefix, e.g. `Turret_`. + pub fn prefix(self) -> &'static str { + match self { + HardpointKind::Bridge => "Bridge_", + HardpointKind::Hatch => "Hatch_", + HardpointKind::ShieldGenerator => "ShieldGenerator_", + HardpointKind::Thruster => "Thruster_", + HardpointKind::Turret => "Turret_", + } + } + /// The `StructureCount` field that counts this kind. + pub fn count_field(self) -> &'static str { + match self { + HardpointKind::Bridge => "BridgeCount", + HardpointKind::Hatch => "HatchCount", + HardpointKind::ShieldGenerator => "ShieldGeneratorCount", + HardpointKind::Thruster => "ThrusterCount", + HardpointKind::Turret => "TurretCount", + } + } + /// Every kind, in record-prefix order. + pub const ALL: [HardpointKind; 5] = [ + HardpointKind::Bridge, + HardpointKind::Hatch, + HardpointKind::ShieldGenerator, + HardpointKind::Thruster, + HardpointKind::Turret, + ]; +} + +/// One destructible component of a craft or vessel — its own record, with its own +/// `HP`. The flat reader could only ever return one `HP` per object; a +/// SD-Battleship has 21 records carrying five distinct values (hull 100000, +/// bridge 10000, thruster 20000, main gun 1500, AA gun 75). +#[derive(Debug, Clone)] +pub struct Hardpoint { + /// The record name, e.g. `Turret_003`. + pub record: String, + pub kind: HardpointKind, + /// The `NNN` suffix of the record name. + pub index: u32, + pub id: Option, + pub name: Option, + pub hp: Option, + pub is_destructible: Option, + pub is_shielded: Option, + pub radius: Option, + /// Damage dealt to the parent hull when this component blows up. + pub spread_damage: Option, + /// Intact model (`NomalModel` on disc — the game's own spelling). + pub model: Option, + pub collision_model: Option, + /// Attachment frame in the parent's scene graph, e.g. `GN_GunL_05_ContH`. + pub frame: Option, + /// Turrets: the weapon they fire. + pub weapon_id: Option, + /// Bridges / thrusters / shield generators: share of the parent's capability. + pub power_ratio: Option, + /// Hatches: the squadron this bay launches. + pub squadron_id: Option, +} + +/// Every `_NNN` record of an object, ordered by kind then index. +fn hardpoints(rs: &RecordSet) -> Vec { + let mut out = Vec::new(); + for kind in HardpointKind::ALL { + for (name, f) in rs.starting_with(kind.prefix()) { + let Ok(index) = name[kind.prefix().len()..].parse::() else { + continue; + }; + out.push(Hardpoint { + record: name.to_string(), + kind, + index, + id: f.text("ID"), + name: f.text("Name"), + hp: f.f32("HP"), + is_destructible: f.bool("IsDestructible"), + is_shielded: f.bool("IsShielded"), + radius: f.f32("Radius"), + spread_damage: f.f32("SpreadDamage"), + model: f.text("NomalModel"), + collision_model: f.text("CollisionModel"), + frame: f.text("Frame"), + weapon_id: f.text("WeaponID"), + power_ratio: f.f32("PowerRatio"), + squadron_id: f.text("SquadronID"), + }); + } + } + out.sort_by_key(|h| (h.kind, h.index)); + out +} + // ── Weapon ──────────────────────────────────────────────────────────────────── -/// A weapon definition (schema [`schema::WEAPON`]). +/// A weapon definition: the launcher (record `Weapon`) plus the projectile it +/// fires (record `Shell`). +/// +/// The two records each carry an `ID`, a `Name` and — with `ShellWake` — an +/// `Interval`, which the old flat reader merged into one answer. They are kept +/// apart here: [`id`](Self::id)/[`name`](Self::name)/[`interval`](Self::interval) +/// are the launcher's, [`shell_id`](Self::shell_id)/[`shell_name`](Self::shell_name) +/// the projectile's, and the tracer's `ShellWake.Interval` is reachable through +/// [`records`](Self::records). #[derive(Debug, Clone)] pub struct Weapon { + /// `Weapon.ID`, e.g. `Weapon_TCAF_DeltaSaber_Rocket_P`. pub id: Option, + /// `Weapon.Name` — a localization key (`WeaponCannonName_…`). pub name: Option, /// Target mask, e.g. `"Vessel,Craft,Structure"`. pub target_type: Option, + /// Seconds between shots (`Weapon.Interval`). + pub interval: Option, + /// Ammo / reload budget (`Weapon.LoadingCount`). + pub loading_count: Option, + /// Shots per trigger pull (volley / lock count). + pub trigger_shot_count: Option, + /// Seconds between the shots of one volley. + pub trigger_shot_interval: Option, + /// Launcher mass (`Weapon.Mass`) — the projectile's own is `Shell.ShellMass`. + pub mass: Option, + pub heating: Option, + pub cooling: Option, + /// Firing pattern, e.g. `Single`, `Burst`. + pub shot_type: Option, + /// `Shell.ID`, e.g. `Shell_TCAF_DeltaSaber_Rocket_P`. + pub shell_id: Option, + /// `Shell.Name` — a localization key (`WeaponShellName_…`). + pub shell_name: Option, pub power: Option, pub velocity: Option, pub min_velocity: Option, pub max_velocity: Option, pub min_range: Option, pub max_range: Option, - /// Ammo / reload budget (`LoadingCount`). - pub loading_count: Option, - /// Seconds between shots. - pub interval: Option, - /// Shots per trigger pull (volley / lock count). - pub trigger_shot_count: Option, - pub mass: Option, - pub heating: Option, - pub cooling: Option, + pub acceleration: Option, pub life_time: Option, - /// All explicitly-set fields (a superset of the named ones above). - pub fields: BTreeMap, + /// `Shell`, `Laser`, `Missile`, … — how the projectile is simulated. + pub movement_type: Option, + pub damage_type: Option, + /// Every record of the source object (`Weapon`, `Shell`, `ShellWake`, + /// `WhiskMissileParam`, `AssortMissileParam`, `BezierMissileParam`). + pub records: RecordSet, } impl Weapon { fn from_idxd(o: &IdxdObject) -> Option { - let f = field_map(o); + let records = RecordSet::from_idxd(o); + let w = records.record("Weapon")?; + let s = records.record("Shell")?; Some(Weapon { - id: o.get_raw("ID").map(str::to_string), - name: o.get_raw("Name").map(str::to_string), - target_type: o.get_raw("TargetType").map(str::to_string), - power: as_f32(f.get("Power")), - velocity: as_f32(f.get("Velocity")), - min_velocity: as_f32(f.get("MinimumVelocity")), - max_velocity: as_f32(f.get("MaximumVelocity")), - min_range: as_f32(f.get("MinimumRange")), - max_range: as_f32(f.get("MaximumRange")), - loading_count: as_i64(f.get("LoadingCount")), - interval: as_f32(f.get("Interval")), - trigger_shot_count: as_i64(f.get("TriggerShotCount")), - mass: as_f32(f.get("Mass")), - heating: as_f32(f.get("Heating")), - cooling: as_f32(f.get("Cooling")), - life_time: as_f32(f.get("LifeTime")), - fields: f, + id: w.text("ID"), + name: w.text("Name"), + target_type: w.text("TargetType"), + interval: w.f32("Interval"), + loading_count: w.i64("LoadingCount"), + trigger_shot_count: w.i64("TriggerShotCount"), + trigger_shot_interval: w.f32("TriggerShotInterval"), + mass: w.f32("Mass"), + heating: w.f32("Heating"), + cooling: w.f32("Cooling"), + shot_type: w.text("ShotType"), + shell_id: s.text("ID"), + shell_name: s.text("Name"), + power: s.f32("Power"), + velocity: s.f32("Velocity"), + min_velocity: s.f32("MinimumVelocity"), + max_velocity: s.f32("MaximumVelocity"), + min_range: s.f32("MinimumRange"), + max_range: s.f32("MaximumRange"), + acceleration: s.f32("Acceleration"), + life_time: s.f32("LifeTime"), + movement_type: s.text("MovementType"), + damage_type: s.text("DamageType"), + records, }) } - /// Any explicit field parsed as `f32` (for the long-tail stats not named above). - pub fn field_f32(&self, key: &str) -> Option { - as_f32(self.fields.get(key)) - } } -/// Load every weapon from a pak. Catches all `Weapon`-shaped records, not just the -/// main [`schema::WEAPON`] — player special/missile weapons live in variant schemas -/// (e.g. `Weapon_DSaber_P_wep_*` in `GP_HANGAR_ARSENAL.pak`). Deduplicated by id. +/// Is this object a weapon definition? Selecting on the records themselves — +/// rather than on `token[0]`, whose first byte is often a stray pool byte +/// (`#Weapon`, `%Weapon`, …) — also catches the 10 objects whose record 0 is a +/// missile-parameter block rather than `Weapon`. Both selectors return the same +/// 131 objects of `GP_MAIN_GAME_E.pak`. +fn is_weapon(o: &IdxdObject) -> bool { + o.record("Weapon").is_some() && o.record("Shell").is_some() +} + +/// Load every weapon from a pak, deduplicated by launcher id. +/// +/// ⚠️ `GP_HANGAR_ARSENAL.pak` holds **no** weapon definitions — its 180 objects +/// carry none of these records. The hangar's weapon *display* entries (name, +/// `Power` grade, `Weight`, `Points`, silhouette model) are a different table; +/// the stats live only in `GP_MAIN_GAME_*.pak`. pub fn load_weapons(pak: &PakArchive) -> Vec { let mut seen = std::collections::BTreeSet::new(); - 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 }; - // `Weapon`/`QWeapon`/… but not the `EnumWeapon` name lists. - let t0 = o.tokens().first().map(String::as_str).unwrap_or(""); - if !t0.ends_with("Weapon") || t0.contains("Enum") { - continue; - } - if let Some(w) = Weapon::from_idxd(&o) { - if w.id.as_deref().is_some_and(|id| seen.insert(id.to_string())) { - out.push(w); - } - } - } - out + load_objects(pak, is_weapon, Weapon::from_idxd) + .into_iter() + .filter(|w| w.id.as_deref().is_some_and(|id| seen.insert(id.to_string()))) + .collect() } // ── Craft / unit ──────────────────────────────────────────────────────────────── -/// A craft / unit — fighter, bomber, turret (schema [`schema::UNIT`]). Carries the -/// AI flight model in [`fields`](CraftUnit::fields) (`AV_Pitch*`, `BarrelRoll_*`, -/// `TurnAttack_*`, …). +/// A craft / unit — identity and hull from `Generic`, flight model from +/// `Maneuver`, component counts from `StructureCount`. +/// +/// ⚠️ [`load_units`] selects objects by record-0 hash (see [`schema::UNIT`]), and +/// that bucket is **not** the same as the game's own `Generic.Type`: of the 89 +/// objects it returns from `GP_MAIN_GAME_E.pak`, 43 declare `Type = Craft` and 46 +/// declare `Type = Vessel`. Filter on [`unit_type`](Self::unit_type) if you mean +/// fighters specifically. #[derive(Debug, Clone)] pub struct CraftUnit { pub id: Option, pub name: Option, + /// `Generic.Type` — `Craft` or `Vessel`, the game's own classification. + pub unit_type: Option, + /// The 3D hull resource stem, e.g. `rou_f001`. + pub model: Option, + /// Hull hit points (`Generic.HP`) — each [`Hardpoint`] has its own. pub hp: Option, pub is_destructible: Option, pub size_x: Option, @@ -172,105 +505,213 @@ pub struct CraftUnit { pub size_radius: Option, pub radar_range: Option, pub fcs_range: Option, + pub score_point: Option, + pub shield_ratio: Option, + /// `Shield.MaxValue` — the rechargeable shield pool. + pub shield_max: Option, pub cruising_velocity: Option, + pub minimum_velocity: Option, pub maximum_velocity: Option, pub acceleration: Option, + /// `Maneuver.Deceleration`. The flat reader answered `NS_Body`'s + /// trail-particle deceleration for the 44 units that have one. pub deceleration: Option, - pub shield_ratio: Option, + /// `StructureCount.TurretCount` — the *active* turrets. It is not the number + /// of `Turret_*` records: the player's Delta Saber says 4 while carrying 63 + /// `Turret_NNN` records, one per equippable weapon. pub turret_count: Option, - pub score_point: Option, - pub fields: BTreeMap, + pub bridge_count: Option, + pub hatch_count: Option, + pub shield_generator_count: Option, + pub thruster_count: Option, + /// Every destructible component, each with its own stats. + pub hardpoints: Vec, + pub records: RecordSet, +} + +/// The `Generic`/`Maneuver`/`StructureCount`/`Shield` stat block shared by craft +/// and vessels — the same records in both tables, read once. +struct Hull { + id: Option, + name: Option, + unit_type: Option, + model: Option, + hp: Option, + is_destructible: Option, + size_x: Option, + size_y: Option, + size_z: Option, + size_radius: Option, + radar_range: Option, + fcs_range: Option, + score_point: Option, + shield_ratio: Option, + shield_max: Option, + cruising_velocity: Option, + minimum_velocity: Option, + maximum_velocity: Option, + acceleration: Option, + deceleration: Option, + turret_count: Option, + bridge_count: Option, + hatch_count: Option, + shield_generator_count: Option, + thruster_count: Option, +} + +impl Hull { + fn read(records: &RecordSet) -> Option { + let g = records.record("Generic")?; + Some(Hull { + id: g.text("ID"), + name: g.text("Name"), + unit_type: g.text("Type"), + model: g.text("Model"), + hp: g.f32("HP"), + is_destructible: g.bool("IsDestructible"), + size_x: g.f32("Size_X"), + size_y: g.f32("Size_Y"), + size_z: g.f32("Size_Z"), + size_radius: g.f32("Size_Radius"), + radar_range: g.f32("RadarRange"), + fcs_range: g.f32("FCSRange"), + score_point: g.i64("ScorePoint"), + shield_ratio: g.f32("ShieldRatio"), + shield_max: records.f32("Shield", "MaxValue"), + cruising_velocity: records.f32("Maneuver", "CruisingVelocity"), + minimum_velocity: records.f32("Maneuver", "MinimumVelocity"), + maximum_velocity: records.f32("Maneuver", "MaximumVelocity"), + acceleration: records.f32("Maneuver", "Acceleration"), + deceleration: records.f32("Maneuver", "Deceleration"), + turret_count: records.i64("StructureCount", "TurretCount"), + bridge_count: records.i64("StructureCount", "BridgeCount"), + hatch_count: records.i64("StructureCount", "HatchCount"), + shield_generator_count: records.i64("StructureCount", "ShieldGeneratorCount"), + thruster_count: records.i64("StructureCount", "ThrusterCount"), + }) + } } impl CraftUnit { fn from_idxd(o: &IdxdObject) -> Option { - let f = field_map(o); + let records = RecordSet::from_idxd(o); + let h = Hull::read(&records)?; Some(CraftUnit { - id: o.get_raw("ID").map(str::to_string), - name: o.get_raw("Name").map(str::to_string), - hp: as_f32(f.get("HP")), - is_destructible: o.get_bool("IsDestructible"), - size_x: as_f32(f.get("Size_X")), - size_y: as_f32(f.get("Size_Y")), - size_z: as_f32(f.get("Size_Z")), - size_radius: as_f32(f.get("Size_Radius")), - radar_range: as_f32(f.get("RadarRange")), - fcs_range: as_f32(f.get("FCSRange")), - cruising_velocity: as_f32(f.get("CruisingVelocity")), - maximum_velocity: as_f32(f.get("MaximumVelocity")), - acceleration: as_f32(f.get("Acceleration")), - deceleration: as_f32(f.get("Deceleration")), - shield_ratio: as_f32(f.get("ShieldRatio")), - turret_count: as_i64(f.get("TurretCount")), - score_point: as_i64(f.get("ScorePoint")), - fields: f, + id: h.id, + name: h.name, + unit_type: h.unit_type, + model: h.model, + hp: h.hp, + is_destructible: h.is_destructible, + size_x: h.size_x, + size_y: h.size_y, + size_z: h.size_z, + size_radius: h.size_radius, + radar_range: h.radar_range, + fcs_range: h.fcs_range, + score_point: h.score_point, + shield_ratio: h.shield_ratio, + shield_max: h.shield_max, + cruising_velocity: h.cruising_velocity, + minimum_velocity: h.minimum_velocity, + maximum_velocity: h.maximum_velocity, + acceleration: h.acceleration, + deceleration: h.deceleration, + turret_count: h.turret_count, + bridge_count: h.bridge_count, + hatch_count: h.hatch_count, + shield_generator_count: h.shield_generator_count, + thruster_count: h.thruster_count, + hardpoints: hardpoints(&records), + records, }) } - pub fn field_f32(&self, key: &str) -> Option { - as_f32(self.fields.get(key)) + /// The AI flight-model parameter `key` (`AV_Pitch*`, `BarrelRoll_*`, …), which + /// all live in the `Maneuver` record. + pub fn maneuver_f32(&self, key: &str) -> Option { + self.records.f32("Maneuver", key) } } -/// Load every craft / unit from a pak. +/// Load every craft / unit from a pak (see the caveat on [`CraftUnit`]). pub fn load_units(pak: &PakArchive) -> Vec { load_table(pak, schema::UNIT, CraftUnit::from_idxd) } // ── Vessel / capital ship ─────────────────────────────────────────────────────── -/// A capital ship (schema [`schema::VESSEL`]). Component counts drive the -/// destructible hardpoints. +/// A capital ship. Same record layout as [`CraftUnit`]; kept separate because +/// [`load_vessels`] selects a different record-0 bucket (see [`schema::VESSEL`], +/// which is `tag_hash("Bridge_000")` — so this bucket is, in effect, "ships whose +/// lowest-hashed record is a bridge"). #[derive(Debug, Clone)] pub struct Vessel { pub id: Option, pub name: Option, + /// `Generic.Type` — `Vessel` for all 23 objects on the retail disc. + pub unit_type: Option, /// The 3D hull resource stem, e.g. `rou_e105` — the `eNNN`/`fNNN` id links to /// the XBG7 part family (`e105_bdy_*`, `e105_brg_*`, …) inside a `Stage_SNN.xpr`. pub model: Option, + /// Hull hit points. Each [`Hardpoint`] carries its own. pub hp: Option, + pub is_destructible: Option, pub size_x: Option, pub size_y: Option, pub size_z: Option, + pub size_radius: Option, pub radar_range: Option, pub fcs_range: Option, - pub maximum_velocity: Option, - pub shield_ratio: Option, pub score_point: Option, + pub shield_ratio: Option, + pub shield_max: Option, + pub cruising_velocity: Option, + pub maximum_velocity: Option, + pub acceleration: Option, + pub deceleration: Option, pub turret_count: Option, pub bridge_count: Option, pub hatch_count: Option, pub shield_generator_count: Option, pub thruster_count: Option, - pub fields: BTreeMap, + /// Every destructible component, each with its own `HP`. + pub hardpoints: Vec, + pub records: RecordSet, } impl Vessel { fn from_idxd(o: &IdxdObject) -> Option { - let f = field_map(o); + let records = RecordSet::from_idxd(o); + let h = Hull::read(&records)?; Some(Vessel { - id: o.get_raw("ID").map(str::to_string), - name: o.get_raw("Name").map(str::to_string), - model: o.get_raw("Model").map(str::to_string), - hp: as_f32(f.get("HP")), - size_x: as_f32(f.get("Size_X")), - size_y: as_f32(f.get("Size_Y")), - size_z: as_f32(f.get("Size_Z")), - radar_range: as_f32(f.get("RadarRange")), - fcs_range: as_f32(f.get("FCSRange")), - maximum_velocity: as_f32(f.get("MaximumVelocity")), - shield_ratio: as_f32(f.get("ShieldRatio")), - score_point: as_i64(f.get("ScorePoint")), - turret_count: as_i64(f.get("TurretCount")), - bridge_count: as_i64(f.get("BridgeCount")), - hatch_count: as_i64(f.get("HatchCount")), - shield_generator_count: as_i64(f.get("ShieldGeneratorCount")), - thruster_count: as_i64(f.get("ThrusterCount")), - fields: f, + id: h.id, + name: h.name, + unit_type: h.unit_type, + model: h.model, + hp: h.hp, + is_destructible: h.is_destructible, + size_x: h.size_x, + size_y: h.size_y, + size_z: h.size_z, + size_radius: h.size_radius, + radar_range: h.radar_range, + fcs_range: h.fcs_range, + score_point: h.score_point, + shield_ratio: h.shield_ratio, + shield_max: h.shield_max, + cruising_velocity: h.cruising_velocity, + maximum_velocity: h.maximum_velocity, + acceleration: h.acceleration, + deceleration: h.deceleration, + turret_count: h.turret_count, + bridge_count: h.bridge_count, + hatch_count: h.hatch_count, + shield_generator_count: h.shield_generator_count, + thruster_count: h.thruster_count, + hardpoints: hardpoints(&records), + records, }) } - pub fn field_f32(&self, key: &str) -> Option { - as_f32(self.fields.get(key)) - } } /// Load every capital ship from a pak. @@ -280,9 +721,113 @@ pub fn load_vessels(pak: &PakArchive) -> Vec { // ── Player config ─────────────────────────────────────────────────────────────── -/// Player craft config (schema [`schema::PLAYER`]) — one per mission. Physics, -/// projectile caps and scoring here; cameras, difficulty and the render pipeline -/// live in [`fields`](PlayerConfig::fields). +/// A difficulty level — the game keeps one `Score_*` and one `Difficulty_*` +/// record per level. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Difficulty { + Easy, + Normal, + Hard, +} + +impl Difficulty { + pub const ALL: [Difficulty; 3] = [Difficulty::Easy, Difficulty::Normal, Difficulty::Hard]; + /// The record-name suffix (`Score_Easy`, `Difficulty_Easy`). + pub fn suffix(self) -> &'static str { + match self { + Difficulty::Easy => "Easy", + Difficulty::Normal => "Normal", + Difficulty::Hard => "Hard", + } + } +} + +/// One value per difficulty. The old API returned a single `main_mission_bonus`, +/// which was the Easy one for every mission. +#[derive(Debug, Clone)] +pub struct ByDifficulty { + pub easy: T, + pub normal: T, + pub hard: T, +} + +impl ByDifficulty { + pub fn get(&self, d: Difficulty) -> &T { + match d { + Difficulty::Easy => &self.easy, + Difficulty::Normal => &self.normal, + Difficulty::Hard => &self.hard, + } + } + pub fn iter(&self) -> impl Iterator { + [ + (Difficulty::Easy, &self.easy), + (Difficulty::Normal, &self.normal), + (Difficulty::Hard, &self.hard), + ] + .into_iter() + } +} + +/// The scoring rules of one difficulty (record `Score_`). +#[derive(Debug, Clone, Default)] +pub struct ScoreRules { + /// Score for completing the main objective — **per difficulty**: on the + /// retail disc it is 1000/2000/4000 or 500/1000/2000 or 1500/3000/6000 + /// (easy/normal/hard) depending on the mission. + pub main_mission_bonus: Option, + /// Number of main objectives the bonus is paid for. + pub main_mission_count: Option, + /// Rank thresholds. Identical across the three difficulty records in every + /// mission of the retail disc — the difficulty scaling is in the *earnings* + /// (`*_Adjustment`, `KillBonus_Maximum`, …), not in the thresholds. + pub rank_score_s: Option, + pub rank_score_a: Option, + pub rank_score_b: Option, + pub rank_score_c: Option, + pub rank_score_d: Option, + pub lost_wingman_penalty: Option, + pub kill_bonus_maximum: Option, +} + +impl ScoreRules { + fn from_record(f: Option<&RecordFields>) -> Self { + let Some(f) = f else { return ScoreRules::default() }; + ScoreRules { + main_mission_bonus: f.i64("MainMissionBonus"), + main_mission_count: f.i64("MainMissionCount"), + rank_score_s: f.i64("RankScore_S"), + rank_score_a: f.i64("RankScore_A"), + rank_score_b: f.i64("RankScore_B"), + rank_score_c: f.i64("RankScore_C"), + rank_score_d: f.i64("RankScore_D"), + lost_wingman_penalty: f.i64("LostWingmanPenalty"), + kill_bonus_maximum: f.i64("KillBonus_Maximum"), + } + } +} + +/// One mission phase's play-space setup (record `Phase_N`), which also carries +/// that phase's whole post-processing/nebula environment in +/// [`PlayerConfig::records`]. +#[derive(Debug, Clone)] +pub struct PlayerPhase { + /// The `N` of `Phase_N`, 1-based. + pub phase: u32, + /// Radius of the playable volume — **per phase**: on the retail disc phase 1 + /// is typically 250000.0 and phases 2/3 100000.0. + pub space_size: Option, + /// Range at which a resupply ship can be docked with. + pub supply_range: Option, + /// The squadron the player commands in this phase. + pub under_command_squadron: Option, + pub supply_squadron_1: Option, + pub supply_squadron_2: Option, +} + +/// Player craft config — one per mission. Physics and projectile caps come from +/// the `Player` record; the play space is **per phase** and scoring is **per +/// difficulty**, so both are lists here rather than one flattened value. #[derive(Debug, Clone)] pub struct PlayerConfig { pub air_drag_factor: Option, @@ -290,39 +835,60 @@ pub struct PlayerConfig { pub bullet_limit: Option, pub laser_limit: Option, pub homing_limit: Option, - pub space_size: Option, - pub supply_range: Option, - pub main_mission_bonus: Option, - pub rank_score_s: Option, - pub rank_score_a: Option, - pub rank_score_b: Option, - pub rank_score_c: Option, - pub rank_score_d: Option, - pub fields: BTreeMap, + pub pitch_adjustment: Option, + pub yaw_adjustment: Option, + pub roll_adjustment: Option, + /// `Phase_1`, `Phase_2`, … in order. + pub phases: Vec, + /// `Score_Easy` / `Score_Normal` / `Score_Hard`. + pub score: ByDifficulty, + pub records: RecordSet, } impl PlayerConfig { fn from_idxd(o: &IdxdObject) -> Option { - let f = field_map(o); + let records = RecordSet::from_idxd(o); + let p = records.record("Player")?; + let mut phases: Vec = records + .starting_with("Phase_") + .filter_map(|(name, f)| { + Some(PlayerPhase { + phase: name["Phase_".len()..].parse().ok()?, + space_size: f.f32("SpaceSize"), + supply_range: f.f32("SupplyRange"), + under_command_squadron: f.text("UnderCommandSquadron"), + supply_squadron_1: f.text("SupplySquadron1"), + supply_squadron_2: f.text("SupplySquadron2"), + }) + }) + .collect(); + phases.sort_by_key(|p| p.phase); Some(PlayerConfig { - air_drag_factor: as_f32(f.get("AirDragFactor")), - gravity_factor: as_f32(f.get("GravityFactor")), - bullet_limit: as_i64(f.get("BulletLimit")), - laser_limit: as_i64(f.get("LaserLimit")), - homing_limit: as_i64(f.get("HomingLimit")), - space_size: as_f32(f.get("SpaceSize")), - supply_range: as_f32(f.get("SupplyRange")), - main_mission_bonus: as_i64(f.get("MainMissionBonus")), - rank_score_s: as_i64(f.get("RankScore_S")), - rank_score_a: as_i64(f.get("RankScore_A")), - rank_score_b: as_i64(f.get("RankScore_B")), - rank_score_c: as_i64(f.get("RankScore_C")), - rank_score_d: as_i64(f.get("RankScore_D")), - fields: f, + air_drag_factor: p.f32("AirDragFactor"), + gravity_factor: p.f32("GravityFactor"), + bullet_limit: p.i64("BulletLimit"), + laser_limit: p.i64("LaserLimit"), + homing_limit: p.i64("HomingLimit"), + pitch_adjustment: p.f32("PitchAdjustment"), + yaw_adjustment: p.f32("YawAdjustment"), + roll_adjustment: p.f32("RollAdjustment"), + phases, + score: ByDifficulty { + easy: ScoreRules::from_record(records.record("Score_Easy")), + normal: ScoreRules::from_record(records.record("Score_Normal")), + hard: ScoreRules::from_record(records.record("Score_Hard")), + }, + records, }) } - pub fn field_f32(&self, key: &str) -> Option { - as_f32(self.fields.get(key)) + /// The play-space radius of `phase` (1-based). + pub fn space_size(&self, phase: u32) -> Option { + self.phases.iter().find(|p| p.phase == phase)?.space_size + } + /// A damage/guidance multiplier from the `Difficulty_` record + /// (`DamageAdjustment`, `ShieldDamageAdjustment`, `FriendlyFireAdjustment`, …). + pub fn difficulty_f32(&self, d: Difficulty, key: &str) -> Option { + self.records.f32(&format!("Difficulty_{}", d.suffix()), key) } } @@ -341,7 +907,8 @@ pub struct Face { pub texture: String, } -/// A character (schema [`CHARACTER`](schema::CHARACTER)) — pilots, crew, comms. +/// A character — pilots, crew, comms. Identity from `Generic`, portraits from the +/// `Faces` record (whose field *names* are the emotion ids). #[derive(Debug, Clone)] pub struct Character { pub id: Option, @@ -350,34 +917,34 @@ pub struct Character { /// Faction / side, e.g. `TCAF` (player) or `ADAN` (enemy). pub faction: Option, pub unique: Option, - /// Portrait set: each emotion face and the texture that draws it. + /// Portrait set, ordered by emotion id. pub faces: Vec, - pub fields: BTreeMap, + pub records: RecordSet, } impl Character { fn from_idxd(o: &IdxdObject) -> Option { - let t = o.tokens(); - // Faces are `(texture, FaceID)` pairs after the `Faces` marker (value- - // before-key: the FaceID names the emotion, the `.t32` before it is its art). - let mut faces = Vec::new(); - if let Some(start) = t.iter().position(|s| s == "Faces") { - for i in (start + 1)..t.len() { - if t[i].starts_with("Face") && i > 0 { - faces.push(Face { - id: t[i].clone(), - texture: t[i - 1].clone(), - }); - } - } - } + let records = RecordSet::from_idxd(o); + let g = records.record("Generic")?; + let faces = records + .record("Faces") + .map(|f| { + f.named + .iter() + .map(|(id, texture)| Face { + id: id.clone(), + texture: texture.clone(), + }) + .collect() + }) + .unwrap_or_default(); Some(Character { - id: o.get_raw("ID").map(str::to_string), - name_key: o.get_raw("Name").map(str::to_string), - faction: o.get_raw("SideID").map(str::to_string), - unique: o.get_bool("Unique"), + id: g.text("ID"), + name_key: g.text("Name"), + faction: g.text("SideID"), + unique: g.bool("Unique"), faces, - fields: field_map(o), + records, }) } } @@ -389,26 +956,33 @@ pub fn load_characters(pak: &PakArchive) -> Vec { // ── Stage / mission ───────────────────────────────────────────────────────────── -/// The raw token immediately before `key` in `tokens` (value-before-key), for -/// identifier-valued fields the generic resolver skips (filenames, locations). -fn raw_before<'a>(tokens: &'a [String], key: &str) -> Option<&'a str> { - let i = tokens.iter().position(|t| t == key)?; - (i > 0).then(|| tokens[i - 1].as_str()) +/// One mission phase's map (record `Phase_N` of the stage manifest). +#[derive(Debug, Clone)] +pub struct StagePhase { + /// The `N` of `Phase_N`, 1-based. + pub phase: u32, + /// Region file, e.g. `s01_p1.rgn`. + pub map_path: Option, + /// Collision mesh, e.g. `s01_p1.col`. + pub map_mesh: Option, + /// Asteroid-field definition table, when the phase has one. + pub asteroid_definition: Option, + pub background_resource_id: Option, } -/// A mission / stage (schema [`STAGE`](schema::STAGE)). The manifest that wires a -/// mission together: its location, phase count, and the per-stage tables that -/// populate it (unit roster, squadrons, formations, flight routes, AI, objectives). -/// Resolve the referenced `*_S.tbl` tables with [`load_records`] for the -/// spawn/formation detail. +/// A mission / stage manifest: its location, its phases, and the per-stage tables +/// that populate it (unit roster, squadrons, formations, flight routes, AI, +/// objectives). Resolve the referenced `*_S.tbl` tables with [`load_records`]. #[derive(Debug, Clone)] pub struct Stage { /// Stage tag derived from the unit-table name, e.g. `"S01"`. pub id: String, /// In-world location (`BackGroundID`), e.g. `Lebendorf`, `Acheron`, `Earth`. pub location: Option, - /// Number of `Phase_N` sections. - pub phases: u32, + /// The `Phase_N` records, in order. Every stage object on the retail disc has + /// exactly three; a phase that is not played still has a record (`S24`'s third + /// has no map). + pub phases: Vec, pub unit_table: Option, pub character_table: Option, pub squadron_table: Option, @@ -417,42 +991,72 @@ pub struct Stage { pub ai_params_table: Option, pub subobjective_table: Option, pub message_table: Option, + /// The 3D package holding the stage's ships, e.g. + /// `game:\hidden\Resource3D\Stage_S02.xpr`. + pub stage_package: Option, + /// The 3D package holding the skybox / backdrop. + pub background_package: Option, /// Every `resource-kind → table` reference in the manifest. pub resources: BTreeMap, + pub records: RecordSet, } impl Stage { fn from_idxd(o: &IdxdObject) -> Option { - let t = o.tokens(); - // Each resource is `table-name → Enumerate` (value-before-key). - let mut resources = BTreeMap::new(); - for i in 1..t.len() { - let key = t[i].as_str(); - if key.starts_with("Enumerate") || key == "MessageSet" || key == "NamePlate" { - resources.insert(key.to_string(), t[i - 1].clone()); - } - } - let unit_table = raw_before(t, "EnumerateUnit").map(str::to_string); + let records = RecordSet::from_idxd(o); + let sr = records.record("StageResource")?; + let resources: BTreeMap = sr + .named + .iter() + .filter(|(k, v)| { + !v.is_empty() + && (k.starts_with("Enumerate") + || k.as_str() == "MessageSet" + || k.as_str() == "NamePlate") + }) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let unit_table = sr.text("EnumerateUnit"); let id = unit_table .as_deref() .and_then(|s| s.strip_prefix("EnumUnit_")) .map(|s| s.trim_end_matches(".tbl").to_string()) .unwrap_or_default(); + let mut phases: Vec = records + .starting_with("Phase_") + .filter_map(|(name, f)| { + Some(StagePhase { + phase: name["Phase_".len()..].parse().ok()?, + map_path: f.text("MapPath"), + map_mesh: f.text("MapMesh"), + asteroid_definition: f.text("AsteroidDefinition"), + background_resource_id: f.text("BackgroundResourceID"), + }) + }) + .collect(); + phases.sort_by_key(|p| p.phase); Some(Stage { id, - location: o.get_raw("BackGroundID").map(str::to_string), - phases: t.iter().filter(|s| s.starts_with("Phase_")).count() as u32, + location: sr.text("BackGroundID"), + phases, unit_table, - character_table: raw_before(t, "EnumerateCharacter").map(str::to_string), - squadron_table: raw_before(t, "EnumerateSquadron").map(str::to_string), - formation_table: raw_before(t, "EnumerateFormation").map(str::to_string), - route_table: raw_before(t, "EnumerateNullFrame").map(str::to_string), - ai_params_table: raw_before(t, "EnumerateAIParams").map(str::to_string), - subobjective_table: raw_before(t, "EnumerateSubobjective").map(str::to_string), - message_table: raw_before(t, "MessageSet").map(str::to_string), + character_table: sr.text("EnumerateCharacter"), + squadron_table: sr.text("EnumerateSquadron"), + formation_table: sr.text("EnumerateFormation"), + route_table: sr.text("EnumerateNullFrame"), + ai_params_table: sr.text("EnumerateAIParams"), + subobjective_table: sr.text("EnumerateSubobjective"), + message_table: sr.text("MessageSet"), + stage_package: sr.text("StageResourcePackage"), + background_package: sr.text("BackGroundPackage"), resources, + records, }) } + /// Number of `Phase_N` records. + pub fn phase_count(&self) -> u32 { + self.phases.len() as u32 + } } /// Load every stage / mission manifest from a pak. @@ -462,76 +1066,88 @@ pub fn load_stages(pak: &PakArchive) -> Vec { // ── Squadron / flight group ───────────────────────────────────────────────────── +/// One craft of a squadron — four consecutive positional slots of the squadron's +/// record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SquadronMember { + /// The unit id flown, e.g. `UN_f001_TCAF_DeltaSaber_T`. + pub unit: String, + /// Radio-chatter set, e.g. `MessageSet_Katana`. + pub message_set: Option, + /// Pilot / character id, e.g. `ELLEN`, `Character_Player_Test`. + pub pilot: Option, + /// How many craft this tuple instantiates into the formation — slot 2 of the + /// member block (1…30 disc-wide). So `members.len()` is the squadron's + /// `Count`, while the craft it puts in the sky is the sum of this. + /// See `docs/re/structures/unit-group-table.md`. + pub unit_count: Option, +} + /// 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`. +/// behaviour, side, and member craft with their pilots. +/// +/// One record per squadron, named with the squadron tag; its members are +/// `4 * Count` positional fields in `(unit, message set, ?, pilot)` groups, which +/// holds for all **1160** squadron records of `GP_MAIN_GAME_E.pak` with no +/// exception — independently of, and agreeing with, the static Python decode in +/// `docs/re/structures/unit-group-table.md`. The old string-pool reader +/// recovered 28 squadrons in total — one per table — with no ids at all. #[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, + /// Squadron tag (`TCN001`, `ADT101`, …) — the record name. + pub id: String, pub formation_id: Option, pub ai_id: Option, /// Faction — `TCAF` (allied) or `ADAN` (enemy). pub side: Option, + /// Declared member count; `members.len()` is derived from the same number. pub count: Option, - /// Member craft (`UN_*` unit ids). - pub members: Vec, + pub disable_interval: Option, + pub members: Vec, + /// The squadron record's remaining fields. + pub fields: RecordFields, } -/// Load every squadron from a pak. Scans all `Enumerate_Squadrons` tables (their -/// schema hash varies per stage, but the table name is stable). +/// The index record of a squadron table lists every squadron id as a field name. +fn squadron_index(o: &IdxdObject) -> Option<&str> { + o.records()? + .iter() + .map(|r| r.name.as_str()) + .find(|n| n.starts_with("Enumerate_Squadron")) +} + +/// Load every squadron from a pak (the per-stage `UnitGroup_S.tbl` tables). 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")) { + let Some(index) = squadron_index(&o).map(str::to_string) else { 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; + }; + let records = RecordSet::from_idxd(&o); + let Some(list) = records.record(&index) else { continue }; + for id in list.named.keys() { + let Some(f) = records.record(id) else { continue }; + let slots: Vec<&str> = f.positional.iter().map(|(_, v)| v.as_str()).collect(); + let members = slots + .chunks_exact(4) + .map(|m| SquadronMember { + unit: m[0].to_string(), + message_set: text(Some(m[1])), + unit_count: as_i64(Some(m[2])), + pilot: text(Some(m[3])), + }) + .collect(); 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(), + id: id.clone(), + formation_id: f.text("FormationID"), + ai_id: f.text("AIID"), + side: f.text("SideID"), + count: f.i64("Count"), + disable_interval: f.bool("DisableInterval"), + members, + fields: f.clone(), }); } } @@ -540,165 +1156,215 @@ pub fn load_squadrons(pak: &PakArchive) -> Vec { // ── Demo message / dialogue line ──────────────────────────────────────────────── -/// One dialogue line (schema [`MESSAGE`](schema::MESSAGE)): who speaks, with which -/// portrait and voice clip, and the text-key(s) of its caption pages. Resolve the -/// page keys against a [`crate::localization::TextIndex`] for the words, and the -/// voice clip against the movie-voice banks for the audio. +/// One dialogue line: who speaks, with which portrait and voice clip, and the +/// text-keys of its caption pages. Resolve the page keys against a +/// [`crate::localization::TextIndex`] for the words, and the voice clip against +/// the movie-voice banks for the audio. +/// +/// The line's parts are the record's first five positional slots — speaker, +/// portrait, delivery, duration, voice — followed by the caption keys. The old +/// prefix-scraping reader recovered 10263 of the **11775** records, and could +/// only attribute a speaker to a minority of them; every record names one. #[derive(Debug, Clone)] pub struct DemoMessage { - /// Line id, e.g. `MSG_VOICE_A_007`. + /// Line id, e.g. `MSG_VOICE_A_007` (the record's `ID` field). pub id: String, - /// Speaker (`CharacterRAYMOND`) when the line names one; many system / - /// continuation lines are unattributed (`None`). + /// The record name, e.g. `Message_007`. + pub record: String, + /// Speaker (`CharacterRAYMOND`) — slot 0. pub character: Option, - /// Portrait / emotion (`FaceRAYMOND_07`). + /// Portrait / emotion (`FaceRAYMOND_07`) — slot 1. pub face: Option, - /// Voice clip token (`VOICE_A_007`). + /// ❔ Slot 2 — the line's radio treatment: `Noise` (2568 lines), `Emergency` + /// (1274), `Killed` (497); `None` on disc (7436) is reported here as `None`. + pub delivery: Option, + /// Slot 3 — a duration in seconds. + pub duration: Option, + /// Voice clip token (`VOICE_A_007`) — slot 4. 11626 of 11775 lines carry a + /// `VOICE_*` token, 132 an unprefixed `DEMO_*` one, and 17 none at all. pub voice_clip: Option, - /// Caption page text keys (`MSG_VOICE_A_007_000_00`, …) — resolve for the text. + /// Declared caption-page count. + pub page_count: Option, + /// Caption page text keys (`MSG_VOICE_A_007_000_00`, …) — slots 5 and up. pub page_keys: Vec, } -/// Load every dialogue line from a pak. Each `Message_NNN` sub-record within the -/// message blobs becomes one [`DemoMessage`]; fields are found by prefix, so the -/// positional/defaulted layout doesn't matter. +/// Load every dialogue line from a pak. One [`DemoMessage`] per `Message_NNN` +/// record. pub fn load_demo_messages(pak: &PakArchive) -> Vec { - let is_marker = |s: &str| { - s.strip_prefix("Message_").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) - }; - 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 != schema::MESSAGE { - continue; - } - let t = o.tokens(); - let marks: Vec = t.iter().enumerate().filter(|(_, s)| is_marker(s)).map(|(i, _)| i).collect(); - for (k, &m) in marks.iter().enumerate() { - let end = marks.get(k + 1).copied().unwrap_or(t.len()); - let slice = &t[m + 1..end]; - let Some(id) = slice.first().cloned() else { continue }; - out.push(DemoMessage { - character: slice.iter().find(|s| s.starts_with("Character")).cloned(), - face: slice.iter().find(|s| s.starts_with("Face")).cloned(), - voice_clip: slice.iter().find(|s| s.starts_with("VOICE_")).cloned(), - page_keys: slice - .iter() - .filter(|s| s.len() > id.len() && s.starts_with(&id) && s[id.len()..].starts_with('_')) - .cloned() - .collect(), - id, - }); - } - } - out + load_objects( + pak, + |o| o.schema_hash == schema::MESSAGE, + |o| Some(RecordSet::from_idxd(o)), + ) + .into_iter() + .flat_map(|records| { + records + .starting_with("Message_") + .filter_map(|(name, f)| { + Some(DemoMessage { + id: f.text("ID")?, + record: name.to_string(), + character: text(f.at(0)), + face: text(f.at(1)), + delivery: text(f.at(2)).filter(|d| d != "None"), + duration: as_f32(f.at(3)), + voice_clip: text(f.at(4)), + page_count: f.i64("PageCount"), + page_keys: f.slots_from(5).filter(|s| !s.is_empty()).map(str::to_string).collect(), + }) + }) + .collect::>() + }) + .collect() } // ── Unit roster (per-mission combatants) ──────────────────────────────────────── -/// Schema of the per-stage `EnumUnit` roster tables. +/// Record 0 hash of the per-stage `EnumUnit` roster tables — i.e. +/// `tag_hash("EnumUnit")`, their single record. 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. +/// battle (an `EnumUnit_S` table). The roster is the **field names** of the +/// single `EnumUnit` record; every value is the empty string. Join the ids +/// against [`load_units`] / [`load_vessels`] for stats. +/// +/// The table isn't tagged with its stage on disc, so [`stage`](Self::stage) is +/// inferred from any `S_`-prefixed prop id 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). + /// Distinct combatant unit ids (`UN_*`), excluding stage props. pub units: Vec, + /// The stage props filtered out of [`units`](Self::units) — asteroids, + /// collision meshes, boxes. + pub props: 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 + load_objects( + pak, + |o| o.schema_hash == ENUM_UNIT_SCHEMA, + |o| { + let records = RecordSet::from_idxd(o); + let list = records.record("EnumUnit")?; + let ids: Vec<&str> = list.named.keys().map(String::as_str).collect(); + let stage = ids.iter().find_map(|s| { + let r = s.trim_start_matches("UN_"); + let b = r.as_bytes(); + (r.len() >= 3 && b[0] == b'S' && b[1].is_ascii_digit() && b[2].is_ascii_digit()) + .then(|| r[..3].to_string()) + }); + let is_prop = + |id: &str| id.contains("Asteroid") || id.contains("cmesh") || id.contains("_Box"); + let roster = UnitRoster { + stage, + units: ids.iter().filter(|i| !is_prop(i)).map(|i| i.to_string()).collect(), + props: ids.iter().filter(|i| is_prop(i)).map(|i| i.to_string()).collect(), + }; + (!roster.units.is_empty()).then_some(roster) + }, + ) } // ── Pilot roster (player squadron assignments) ────────────────────────────────── -/// The player squadron's flight assignments for a mission — which pilot flies each -/// callsign (`Rhino1` → `Katana`, `Bird1` → `Sandra`, …). Parsed from the -/// `UNITS`/`ZUNITS` player-unit tables (in `GP_HANGAR_ARSENAL.pak`); assignments -/// vary per mission (different missions field different wingmen). +/// One flight assignment: a callsign, its pilot, and the craft and loadout they +/// take up. The record is named `-` and holds the references. +#[derive(Debug, Clone)] +pub struct FlightAssignment { + /// e.g. `Rhino1`. + pub callsign: String, + /// e.g. `Katana`. + pub pilot: String, + /// The unit id flown, or a bare pilot key (`ANTONIUS`) for a wingman whose + /// craft is resolved elsewhere. + pub unit_id: Option, + /// Loadout-set record names (`S01SET_NOSE`, `PlayerSET_ARM1`, …); resolve + /// against the same object's records for the weapons. + pub nose: Option, + pub arm1: Option, + pub arm2: Option, + pub arm3: Option, + /// True when the record carries the `PlayerUnit` marker — this is the seat + /// the player occupies in this mission. + pub is_player: bool, +} + +/// The player squadron's flight assignments for a mission — which pilot flies +/// each callsign (`Rhino1` → `Katana`, `Bird1` → `Sandra`, …). Parsed from the +/// `UNITS` player-unit tables (in `GP_HANGAR_ARSENAL.pak`); assignments vary per +/// mission. #[derive(Debug, Clone)] pub struct PilotRoster { - /// `(callsign, pilot)` pairs in flight order, e.g. `("Rhino1", "Katana")`. - pub pilots: Vec<(String, String)>, - /// The player craft unit id (`UN_f002_TCAF_DeltaSaber…`). + pub assignments: Vec, + /// The player craft unit id (`UN_f001_TCAF_DeltaSaber_T_Player`), from the + /// assignment carrying the `PlayerUnit` marker. pub player_unit: Option, } -/// True for a `Callsign-Pilot` token (a single hyphen, digit-suffixed callsign, -/// alphabetic pilot) — the flight-assignment shape. -fn is_assignment(s: &str) -> bool { - let Some((cs, pilot)) = s.split_once('-') else { return false }; - !pilot.is_empty() - && pilot.chars().all(|c| c.is_ascii_alphabetic()) - && cs.len() >= 2 - && cs.as_bytes()[cs.len() - 1].is_ascii_digit() - && cs.chars().all(|c| c.is_ascii_alphanumeric()) +impl PilotRoster { + /// `(callsign, pilot)` pairs in record order. + pub fn pilots(&self) -> Vec<(String, String)> { + self.assignments + .iter() + .map(|a| (a.callsign.clone(), a.pilot.clone())) + .collect() + } } -/// Load every player pilot roster from a pak (scan `GP_HANGAR_ARSENAL.pak`). One -/// per player-unit config; rosters with no assignment tokens are skipped. +/// Load every player pilot roster from a pak (`GP_HANGAR_ARSENAL.pak`), one per +/// player-unit config. pub fn load_pilot_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 }; - let t = o.tokens(); - let pilots: Vec<(String, String)> = t - .iter() - .filter(|s| is_assignment(s)) - .filter_map(|s| s.split_once('-').map(|(c, p)| (c.to_string(), p.to_string()))) - .collect(); - if pilots.len() >= 2 { - out.push(PilotRoster { - pilots, - player_unit: t.iter().find(|s| s.starts_with("UN_")).cloned(), - }); - } - } - out + load_objects( + pak, + |o| o.record("UNITS").is_some(), + |o| { + let records = RecordSet::from_idxd(o); + let list = records.record("UNITS")?; + let mut assignments = Vec::new(); + let mut player_unit = None; + for name in list.named.keys() { + let Some(f) = records.record(name) else { continue }; + let Some((callsign, pilot)) = name.split_once('-') else { + continue; + }; + let is_player = f.get("PlayerUnit").is_some(); + if is_player { + player_unit = f.text("UnitID"); + } + assignments.push(FlightAssignment { + callsign: callsign.to_string(), + pilot: pilot.to_string(), + unit_id: f.text("UnitID"), + nose: f.text("Nose"), + arm1: f.text("Arm1"), + arm2: f.text("Arm2"), + arm3: f.text("Arm3"), + is_player, + }); + } + (!assignments.is_empty()).then_some(PilotRoster { + assignments, + player_unit, + }) + }, + ) } // ── Arsenal (player weapon options per hardpoint) ─────────────────────────────── -/// The player's selectable weapons, by hardpoint (from the `UNITS` player-unit -/// tables in `GP_HANGAR_ARSENAL.pak`). These are the arsenal display ids — the -/// Delta Saber's nose guns (`Stiletto_BG1`, `Broad_Sword_SG1`, …) and arm missiles -/// (`Falcon_9AM`, `White_Shark_T53R`, …) the player equips in the hangar. +/// The player's selectable weapons, by hardpoint — the positional lists of the +/// `STANDARD_` records of the `UNITS` player-unit tables in +/// `GP_HANGAR_ARSENAL.pak`. +/// +/// The old string-pool scrape walked forward from the header token and swept up +/// whatever followed: it returned 16 "nose" options of which 8 were field keys +/// and pilot names (`Arm1`, `Nose`, `NULL_ARM1`, `UN_f001_TCAF_DeltaSaber_T_Ttrl`), +/// and 47 for `arm3`, of which 38 were junk — while missing `Mine_B2A` from +/// `arm1` and `No_Equipment` from `arm2`. #[derive(Debug, Clone, Default)] pub struct Arsenal { /// Nose slot — the fixed forward gun options. @@ -709,46 +1375,28 @@ pub struct Arsenal { pub arm3: Vec, } -/// The weapon ids listed under a `STANDARD_` header (skipping its `Type` key), -/// up to the next `STANDARD_`/`PlayerSET_` header. -fn hardpoint_options(tokens: &[String], header: &str) -> Vec { - let Some(start) = tokens.iter().position(|s| s == header) else { return Vec::new() }; - tokens[start + 1..] - .iter() - .skip_while(|s| *s == "Type") - .take_while(|s| !s.starts_with("STANDARD_") && !s.starts_with("PlayerSET")) - .filter(|s| *s != "Type") - .cloned() - .collect() -} - -/// Load the player arsenal from a pak (`GP_HANGAR_ARSENAL.pak`). Unions the weapon +/// Load the player arsenal from a pak (`GP_HANGAR_ARSENAL.pak`). Unions the /// options across every player-unit config, deduped in first-seen order. pub fn load_arsenal(pak: &PakArchive) -> Arsenal { let mut a = Arsenal::default(); let mut seen = [(); 4].map(|_| std::collections::BTreeSet::new()); - let slots: [(&str, usize); 4] = [ - ("STANDARD_NOSE", 0), - ("STANDARD_ARM1", 1), - ("STANDARD_ARM2", 2), - ("STANDARD_ARM3", 3), - ]; for e in pak.entries() { let Ok(b) = pak.read(e) else { continue }; let Ok(o) = IdxdObject::parse(&b) else { continue }; - if !o.tokens().first().is_some_and(|s| s.ends_with("UNITS")) { - continue; - } - for (header, idx) in slots { + let records = RecordSet::from_idxd(&o); + for (idx, slot) in ["NOSE", "ARM1", "ARM2", "ARM3"].into_iter().enumerate() { + let Some(f) = records.record(&format!("STANDARD_{slot}")) else { + continue; + }; let dst = match idx { 0 => &mut a.nose, 1 => &mut a.arm1, 2 => &mut a.arm2, _ => &mut a.arm3, }; - for w in hardpoint_options(o.tokens(), header) { - if seen[idx].insert(w.clone()) { - dst.push(w); + for (_, w) in &f.positional { + if !w.is_empty() && seen[idx].insert(w.clone()) { + dst.push(w.clone()); } } } @@ -756,43 +1404,45 @@ pub fn load_arsenal(pak: &PakArchive) -> Arsenal { a } -// ── Generic record access (any of the ~105 schemas) ───────────────────────────── +// ── Generic record access (any table) ────────────────────────────────────────── -/// A generically-decoded IDXD record: its table name, schema id, identity, and -/// every explicitly-set field. Use this to read the ~99 schemas without a bespoke -/// struct (missions, UI layouts, effects, enums, …). +/// A generically-decoded IDXD object: its record-0 name, header hash, identity +/// and every record. Use this to read the tables without a bespoke struct +/// (missions, UI layouts, effects, enums, …). #[derive(Debug, Clone)] pub struct Record { - /// The table name — `token[0]` (e.g. `Weapon`, `StageResource`, `Sperkers`). - /// May carry a stray leading byte from the string-pool boundary on some - /// records; group by [`schema`](Self::schema), not this, for reliability. + /// Record 0's name (e.g. `StageResource`) — the name whose hash the header + /// word carries. Exact: it comes from the record table, not from the first + /// string-pool token, which often begins with a stray pool byte (`#Weapon`). pub table: String, + /// The header word at `0x08` — record 0's name hash. See [`schema`]. pub schema: u32, + /// The first non-empty `ID` in record order. pub id: Option, + /// The first non-empty `Name` in record order. pub name: Option, - /// Every explicitly-set field (`key → value`). - pub fields: BTreeMap, + pub records: RecordSet, } impl Record { fn from_idxd(o: &IdxdObject) -> Option { + let records = RecordSet::from_idxd(o); + let first = |field: &str| { + records + .names() + .find_map(|n| records.record(n)?.text(field)) + }; Some(Record { - table: o.tokens().first().cloned().unwrap_or_default(), + table: records.first_name()?.to_string(), schema: o.schema_hash, - id: o.get_raw("ID").map(str::to_string), - name: o.get_raw("Name").map(str::to_string), - fields: field_map(o), + id: first("ID"), + name: first("Name"), + records, }) } - pub fn f32(&self, key: &str) -> Option { - as_f32(self.fields.get(key)) - } - pub fn i64(&self, key: &str) -> Option { - as_i64(self.fields.get(key)) - } } -/// Load every record of an arbitrary `schema` from a pak, generically. Pair with +/// Load every object whose record 0 hashes to `schema`, generically. Pair with /// the [`schema`] constants or a hash from the IDXD census. pub fn load_records(pak: &PakArchive, schema: u32) -> Vec { load_table(pak, schema, Record::from_idxd) @@ -804,9 +1454,20 @@ mod tests { #[test] fn as_f32_tolerates_trailing_f() { - assert_eq!(as_f32(Some(&"3.0f".to_string())), Some(3.0)); - assert_eq!(as_f32(Some(&"-0.5".to_string())), Some(-0.5)); - assert_eq!(as_f32(Some(&"Vessel,Craft".to_string())), None); + assert_eq!(as_f32(Some("3.0f")), Some(3.0)); + assert_eq!(as_f32(Some("-0.5")), Some(-0.5)); + assert_eq!(as_f32(Some("Vessel,Craft")), None); + } + + #[test] + fn empty_string_reads_as_absent_but_stays_readable() { + let mut f = RecordFields::default(); + f.named.insert("Model".into(), String::new()); + f.named.insert("HP".into(), "12.5".into()); + assert_eq!(f.get("Model"), Some("")); + assert_eq!(f.text("Model"), None); + assert_eq!(f.f32("HP"), Some(12.5)); + assert_eq!(f.get("Nope"), None); } // Integration tests against a real disc — skipped unless SYLPHEED_DISC is set. @@ -819,62 +1480,92 @@ mod tests { fn loads_weapons() { let Some(pak) = pak() else { return }; let ws = load_weapons(&pak); - assert!(ws.len() >= 100, "expected ≥100 weapons, got {}", ws.len()); + assert_eq!(ws.len(), 131, "weapon definitions in GP_MAIN_GAME_E.pak"); let rocket = ws .iter() - .find(|w| w.id.as_deref().is_some_and(|s| s.contains("DeltaSaber_Rocket"))) + .find(|w| w.id.as_deref() == Some("Weapon_TCAF_DeltaSaber_Rocket_P")) .expect("Delta Saber rocket present"); - assert_eq!(rocket.velocity, Some(3000.0)); - assert_eq!(rocket.max_range, Some(6000.0)); + // Launcher record. + assert_eq!(rocket.interval, Some(1.0)); assert_eq!(rocket.loading_count, Some(1000)); assert_eq!(rocket.target_type.as_deref(), Some("Vessel,Craft,Structure")); + assert_eq!(rocket.shot_type.as_deref(), Some("Single")); + // Shell record — a separate ID/Name the flat reader merged with the above. + assert_eq!(rocket.shell_id.as_deref(), Some("Shell_TCAF_DeltaSaber_Rocket_P")); + assert_eq!(rocket.power, Some(100.0)); + assert_eq!(rocket.velocity, Some(3000.0)); + assert_eq!(rocket.max_range, Some(6000.0)); + assert_eq!(rocket.min_range, Some(10.0)); // was None through the pool reader + assert_eq!(rocket.life_time, Some(3.0)); + assert_eq!(rocket.movement_type.as_deref(), Some("Shell")); + // The tracer's own interval stays reachable, and is not the weapon's. + assert_eq!(rocket.records.f32("ShellWake", "Interval"), Some(0.05)); } #[test] fn loads_units_and_vessels() { let Some(pak) = pak() else { return }; let units = load_units(&pak); - assert!(units.len() >= 80); - // Player craft: HP 1000, radar 500000. + assert_eq!(units.len(), 89); let player = units .iter() - .find(|u| u.id.as_deref().is_some_and(|s| s.contains("DeltaSaber_T"))) + .find(|u| u.id.as_deref() == Some("UN_f001_TCAF_DeltaSaber_T")) .expect("player craft present"); assert_eq!(player.hp, Some(1000.0)); assert_eq!(player.radar_range, Some(500000.0)); + // All four were None through the string-pool reader. + assert_eq!(player.fcs_range, Some(500000.0)); + assert_eq!(player.shield_ratio, Some(1.0)); + assert_eq!(player.cruising_velocity, Some(700.0)); + assert_eq!(player.maximum_velocity, Some(1200.0)); + // Maneuver's deceleration, not the NS_Body trail particle's. + assert_eq!(player.deceleration, Some(400.0)); + assert_eq!(player.acceleration, Some(600.0)); + assert_eq!(player.shield_max, Some(2000.0)); let vessels = load_vessels(&pak); + assert_eq!(vessels.len(), 23); let flagship = vessels .iter() - .find(|v| v.id.as_deref().is_some_and(|s| s.contains("SDBattleship"))) + .find(|v| v.id.as_deref() == Some("UN_e101_ADAN_SDBattleship")) .expect("SD-Battleship present"); assert_eq!(flagship.hp, Some(100000.0)); assert_eq!(flagship.turret_count, Some(25)); + assert_eq!(flagship.model.as_deref(), Some("rou_e101")); + assert_eq!(flagship.fcs_range, Some(45000.0)); + assert_eq!(flagship.size_z, Some(12300.0)); } #[test] fn loads_player_configs() { let Some(pak) = pak() else { return }; let cfgs = load_player_configs(&pak); - assert!(!cfgs.is_empty()); + assert_eq!(cfgs.len(), 24); let c = &cfgs[0]; assert_eq!(c.bullet_limit, Some(512)); assert_eq!(c.laser_limit, Some(32)); - assert_eq!(c.rank_score_s, Some(10000)); + assert_eq!(c.homing_limit, Some(256)); + assert_eq!(c.score.normal.rank_score_s, Some(10000)); + assert_eq!(c.phases.len(), 3); } #[test] fn loads_characters() { let Some(pak) = pak() else { return }; let chars = load_characters(&pak); - assert!(chars.len() >= 20, "expected many characters, got {}", chars.len()); + assert_eq!(chars.len(), 68); let raymond = chars .iter() .find(|c| c.id.as_deref() == Some("CharacterRAYMOND")) .expect("Raymond present"); assert_eq!(raymond.faction.as_deref(), Some("TCAF")); - assert!(!raymond.faces.is_empty()); + assert_eq!(raymond.unique, Some(true)); + assert_eq!(raymond.faces.len(), 13); assert!(raymond.faces.iter().all(|f| f.texture.ends_with(".t32"))); + assert_eq!( + raymond.faces.iter().find(|f| f.id == "FaceRAYMOND_07").map(|f| f.texture.as_str()), + Some("pjf003_C02.t32") + ); } #[test] @@ -883,9 +1574,10 @@ mod tests { let stages = load_stages(&pak); let s01 = stages.iter().find(|s| s.id == "S01").expect("stage S01 present"); assert_eq!(s01.location.as_deref(), Some("Lebendorf")); - assert_eq!(s01.phases, 3); + assert_eq!(s01.phase_count(), 3); assert_eq!(s01.unit_table.as_deref(), Some("EnumUnit_S01.tbl")); assert_eq!(s01.squadron_table.as_deref(), Some("UnitGroup_S01.tbl")); + assert!(s01.stage_package.as_deref().is_some_and(|p| p.contains("Stage_S01"))); // 16-mission main campaign is present. for n in 1..=16 { assert!(stages.iter().any(|s| s.id == format!("S{n:02}")), "missing S{n:02}"); @@ -896,46 +1588,40 @@ mod tests { 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. + assert_eq!(sq.len(), 1160, "squadron records across all stage tables"); + // Every squadron's member list matches its declared count. + assert!(sq.iter().all(|s| s.members.len() as i64 == s.count.unwrap_or(-1))); + // The player's Rhino flight: 2 Delta Sabers, Katana leading. 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")) + s.id == "TCN001" && s.formation_id.as_deref() == Some("Formation_2_Rhino1") }) - .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"))); + .expect("TCN001 Rhino flight"); + assert_eq!(rhino.side.as_deref(), Some("TCAF")); + assert_eq!(rhino.count, Some(2)); + assert!(rhino.members.iter().all(|m| m.unit.contains("DeltaSaber"))); + assert_eq!(rhino.members[0].message_set.as_deref(), Some("MessageSet_Katana")); } #[test] fn loads_demo_messages() { let Some(pak) = pak() else { return }; let msgs = load_demo_messages(&pak); - assert!(msgs.len() > 200, "expected many dialogue lines, got {}", msgs.len()); - // Most lines are `MSG_*` ids with a speaker; page keys extend the id. - let msg_ids = msgs.iter().filter(|m| m.id.starts_with("MSG_")).count(); - assert!(msg_ids > msgs.len() * 9 / 10, "{msg_ids}/{}", msgs.len()); - // Attributed dialogue (an explicit speaker) is a minority — most lines are - // unattributed system/continuation messages — but there are hundreds. - let attributed = msgs.iter().filter(|m| m.character.is_some()).count(); - assert!(attributed > 500, "attributed lines: {attributed}"); - let m = msgs.iter().find(|m| m.character.is_some() && !m.page_keys.is_empty()).unwrap(); - assert!(m.page_keys.iter().all(|k| k.starts_with(&m.id))); + assert_eq!(msgs.len(), 11775); + // Every line names a speaker and a portrait; the old reader attributed + // only a minority. + assert!(msgs.iter().all(|m| m.character.is_some() && m.face.is_some())); + assert_eq!(msgs.iter().filter(|m| m.voice_clip.is_some()).count(), 11758); + assert!(msgs.iter().all(|m| !m.page_keys.is_empty())); } #[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")) - })); + assert_eq!(rosters.len(), 31); + assert!(rosters.iter().all(|r| 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")))); @@ -946,13 +1632,19 @@ mod tests { let Ok(disc) = std::env::var("SYLPHEED_DISC") else { return }; let Ok(pak) = PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")) else { return }; let rosters = load_pilot_rosters(&pak); - assert!(!rosters.is_empty(), "expected player pilot rosters"); - // Some config assigns Katana to a Rhino callsign (she leads Rhino flight). + assert_eq!(rosters.len(), 168); + // Every config seats the player and names the craft flown. + assert!(rosters.iter().all(|r| r.player_unit.is_some())); assert!(rosters.iter().any(|r| { - r.pilots.iter().any(|(cs, p)| cs.starts_with("Rhino") && p == "Katana") + r.assignments.iter().any(|a| a.callsign.starts_with("Rhino") && a.pilot == "Katana") })); - // Bird flight exists too. - assert!(rosters.iter().any(|r| r.pilots.iter().any(|(cs, _)| cs.starts_with("Bird")))); + assert!(rosters.iter().any(|r| r.assignments.iter().any(|a| a.callsign.starts_with("Bird")))); + let a = rosters + .iter() + .flat_map(|r| &r.assignments) + .find(|a| a.is_player) + .expect("a player seat"); + assert!(a.nose.is_some() && a.arm1.is_some()); } #[test] @@ -960,21 +1652,25 @@ mod tests { let Ok(disc) = std::env::var("SYLPHEED_DISC") else { return }; let Ok(pak) = PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")) else { return }; let a = load_arsenal(&pak); - assert!(a.nose.len() >= 5 && a.arm1.len() >= 5, "nose {} arm1 {}", a.nose.len(), a.arm1.len()); + assert_eq!((a.nose.len(), a.arm1.len(), a.arm2.len(), a.arm3.len()), (8, 12, 9, 9)); assert!(a.nose.iter().any(|w| w.starts_with("Stiletto"))); assert!(a.arm1.iter().any(|w| w.starts_with("Falcon"))); - // Headers never leak into the option lists. - assert!([&a.nose, &a.arm1, &a.arm2, &a.arm3] - .iter() - .all(|v| v.iter().all(|w| !w.starts_with("STANDARD_") && *w != "Type"))); + // No field keys, headers or pilot names leak into the option lists — the + // failure mode of the string-pool scrape this replaced. + for list in [&a.nose, &a.arm1, &a.arm2, &a.arm3] { + assert!(list.iter().all(|w| !w.starts_with("STANDARD_") + && !w.starts_with("NULL_") + && !w.starts_with("UN_") + && !["Type", "Nose", "Arm1", "Arm2", "Arm3", "PlayerUnit"].contains(&w.as_str()))); + } } #[test] fn generic_records_read_any_schema() { let Some(pak) = pak() else { return }; - // Stage-resource table via the generic reader (no bespoke struct). - let stages = load_records(&pak, 0x3c9a_e32e); - assert!(!stages.is_empty()); - assert!(stages.iter().all(|r| r.table.ends_with("StageResource"))); + let stages = load_records(&pak, schema::STAGE); + assert_eq!(stages.len(), 29); + // Record 0's name is exact — no stray string-pool byte in front of it. + assert!(stages.iter().all(|r| r.table == "StageResource")); } } diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index fe17da7..acf9f01 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -4021,9 +4021,9 @@ fn build_game_snapshot(source: &SourceKind) -> Option { continue; } let objectives: Vec = - (1..=s.phases).flat_map(|p| text.objectives(&s.id, p)).map(str::to_string).collect(); + (1..=s.phase_count()).flat_map(|p| text.objectives(&s.id, p)).map(str::to_string).collect(); let lose: Vec = - (1..=s.phases).flat_map(|p| text.lose_conditions(&s.id, p)).map(str::to_string).collect(); + (1..=s.phase_count()).flat_map(|p| text.lose_conditions(&s.id, p)).map(str::to_string).collect(); let enemies: Vec = rosters .iter() .find(|r| r.stage.as_deref() == Some(s.id.as_str())) @@ -4039,8 +4039,8 @@ fn build_game_snapshot(source: &SourceKind) -> Option { .unwrap_or_default(); missions.push(MissionRow { id: s.id.clone(), - location: s.location.unwrap_or_default().replace('_', " "), - phases: s.phases, + location: s.location.clone().unwrap_or_default().replace('_', " "), + phases: s.phase_count(), objectives, lose, enemies, @@ -4055,9 +4055,9 @@ fn build_game_snapshot(source: &SourceKind) -> Option { let mut seen = std::collections::BTreeSet::new(); let mut flights = Vec::new(); for r in gd::load_pilot_rosters(&h) { - let key: String = r.pilots.iter().map(|(c, p)| format!("{c}:{p}")).collect::>().join(","); + let key: String = r.pilots().iter().map(|(c, p)| format!("{c}:{p}")).collect::>().join(","); if seen.insert(key) { - flights.push(r.pilots); + flights.push(r.pilots()); } } (arsenal, flights)