//! Typed loaders for Project Sylpheed's combat data tables. //! //! The game keeps its balance data in reflective [`crate::idxd`] tables — one //! 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. //! //! ## 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}; //! let pak = PakArchive::open("dat/GP_MAIN_GAME_E.pak").unwrap(); //! for w in game_data::load_weapons(&pak) { //! println!("{} — power {:?}, range {:?}", w.id.unwrap_or_default(), w.power, w.max_range); //! } //! ``` use crate::idxd::IdxdObject; use crate::pak::PakArchive; use std::collections::BTreeMap; /// 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). 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 — 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<&str>) -> Option { let v = v?; v.strip_suffix(['f', 'F']).unwrap_or(v).parse().ok() } fn as_i64(v: Option<&str>) -> Option { v?.parse().ok() } fn as_bool(v: Option<&str>) -> Option { match v? { "Yes" | "YES" | "On" | "ON" | "1" => Some(true), "No" | "NO" | "Off" | "OFF" | "0" => Some(false), _ => None, } } /// 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| 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: 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, pub acceleration: Option, pub life_time: Option, /// `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 records = RecordSet::from_idxd(o); let w = records.record("Weapon")?; let s = records.record("Shell")?; Some(Weapon { 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, }) } } /// 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(); 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 — 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, pub size_y: Option, pub size_z: Option, 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, /// `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 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 records = RecordSet::from_idxd(o); let h = Hull::read(&records)?; Some(CraftUnit { 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, }) } /// 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 (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. 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 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, /// Every destructible component, each with its own `HP`. pub hardpoints: Vec, pub records: RecordSet, } impl Vessel { fn from_idxd(o: &IdxdObject) -> Option { let records = RecordSet::from_idxd(o); let h = Hull::read(&records)?; Some(Vessel { 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, }) } } /// Load every capital ship from a pak. pub fn load_vessels(pak: &PakArchive) -> Vec { load_table(pak, schema::VESSEL, Vessel::from_idxd) } // ── Player config ─────────────────────────────────────────────────────────────── /// 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, pub gravity_factor: Option, pub bullet_limit: Option, pub laser_limit: Option, pub homing_limit: Option, 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 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: 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, }) } /// 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) } } /// Load every player config (one per mission) from a pak. pub fn load_player_configs(pak: &PakArchive) -> Vec { load_table(pak, schema::PLAYER, PlayerConfig::from_idxd) } // ── Character ─────────────────────────────────────────────────────────────────── /// A pilot / crew portrait: an emotion id (`FaceRAYMOND_07`) and its texture /// (`pjf003_C02.t32`, a T8aD tile inside a RATC bundle). #[derive(Debug, Clone)] pub struct Face { pub id: String, pub texture: String, } /// 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, /// Localized display-name key (resolve against the game's string tables). pub name_key: Option, /// Faction / side, e.g. `TCAF` (player) or `ADAN` (enemy). pub faction: Option, pub unique: Option, /// Portrait set, ordered by emotion id. pub faces: Vec, pub records: RecordSet, } impl Character { fn from_idxd(o: &IdxdObject) -> Option { 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: g.text("ID"), name_key: g.text("Name"), faction: g.text("SideID"), unique: g.bool("Unique"), faces, records, }) } } /// Load every character from a pak. pub fn load_characters(pak: &PakArchive) -> Vec { load_table(pak, schema::CHARACTER, Character::from_idxd) } // ── Stage / mission ───────────────────────────────────────────────────────────── /// 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 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, /// 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, pub formation_table: Option, pub route_table: Option, 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 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: sr.text("BackGroundID"), phases, unit_table, 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. pub fn load_stages(pak: &PakArchive) -> Vec { load_table(pak, schema::STAGE, Stage::from_idxd) } // ── 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. /// /// 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`, …) — 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, pub disable_interval: Option, pub members: Vec, /// The squadron record's remaining fields. pub fields: RecordFields, } /// 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 Some(index) = squadron_index(&o).map(str::to_string) else { continue; }; 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: 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(), }); } } out } // ── Demo message / dialogue line ──────────────────────────────────────────────── /// 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` (the record's `ID` field). pub id: String, /// The record name, e.g. `Message_007`. pub record: String, /// Speaker (`CharacterRAYMOND`) — slot 0. pub character: Option, /// Portrait / emotion (`FaceRAYMOND_07`) — slot 1. pub face: Option, /// ❔ 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, /// 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. One [`DemoMessage`] per `Message_NNN` /// record. pub fn load_demo_messages(pak: &PakArchive) -> Vec { 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) ──────────────────────────────────────── /// 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). 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. 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 { 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) ────────────────────────────────── /// 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 { 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, } 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 (`GP_HANGAR_ARSENAL.pak`), one per /// player-unit config. pub fn load_pilot_rosters(pak: &PakArchive) -> Vec { 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 — 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. pub nose: Vec, /// The three arm hardpoints — missile / bomb / special options. pub arm1: Vec, pub arm2: Vec, pub arm3: Vec, } /// 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()); for e in pak.entries() { let Ok(b) = pak.read(e) else { continue }; let Ok(o) = IdxdObject::parse(&b) else { continue }; 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 &f.positional { if !w.is_empty() && seen[idx].insert(w.clone()) { dst.push(w.clone()); } } } } a } // ── Generic record access (any table) ────────────────────────────────────────── /// 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 { /// 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, 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: records.first_name()?.to_string(), schema: o.schema_hash, id: first("ID"), name: first("Name"), records, }) } } /// 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) } #[cfg(test)] mod tests { use super::*; #[test] fn as_f32_tolerates_trailing_f() { 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. fn pak() -> Option { let disc = std::env::var("SYLPHEED_DISC").ok()?; PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).ok() } #[test] fn loads_weapons() { let Some(pak) = pak() else { return }; let ws = load_weapons(&pak); assert_eq!(ws.len(), 131, "weapon definitions in GP_MAIN_GAME_E.pak"); let rocket = ws .iter() .find(|w| w.id.as_deref() == Some("Weapon_TCAF_DeltaSaber_Rocket_P")) .expect("Delta Saber rocket present"); // 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_eq!(units.len(), 89); let player = units .iter() .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() == 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_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.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_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_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] fn loads_stages() { let Some(pak) = pak() else { return }; 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.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}"); } } #[test] fn loads_squadrons() { let Some(pak) = pak() else { return }; let sq = load_squadrons(&pak); 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.id == "TCN001" && s.formation_id.as_deref() == Some("Formation_2_Rhino1") }) .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_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_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")))); } #[test] fn loads_pilot_rosters() { 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_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.assignments.iter().any(|a| a.callsign.starts_with("Rhino") && a.pilot == "Katana") })); 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] fn loads_arsenal() { 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_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"))); // 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 }; 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")); } }