//! 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. //! //! 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. //! //! ```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; /// IDXD schema ids of the combat tables (the `schema_hash` field of each blob). pub mod schema { /// Player craft config: physics, cameras, scoring, difficulty, render pipeline. pub const PLAYER: u32 = 0x0426_e81d; /// Weapon definitions (guns, missiles, beams, bombs). pub const WEAPON: u32 = 0x6ab4_825a; /// Craft / units — fighters, bombers, turrets, with their AI flight model. pub const UNIT: u32 = 0x43fa_a517; /// Vessels — capital ships, with structural component counts. pub const VESSEL: u32 = 0x3c5b_0549; /// Characters — pilots / crew / comms, with faction and portrait set. pub const CHARACTER: u32 = 0xbd86_d41c; /// Stage resource manifest — per mission: location, phases, resource tables. pub const STAGE: u32 = 0x3c9a_e32e; /// Message / demo dialogue — per line: speaker, portrait, voice clip, pages. pub const MESSAGE: u32 = 0xb412_e6d8; } /// Parse a numeric field value, tolerating a trailing `f`/`F` (e.g. `"3.0f"`). fn as_f32(v: Option<&String>) -> Option { let v = v?; let v = v.strip_suffix(['f', 'F']).unwrap_or(v); v.parse().ok() } fn as_i64(v: Option<&String>) -> 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() } /// 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 { pak.entries() .iter() .filter_map(|e| pak.read(e).ok()) .filter_map(|b| IdxdObject::parse(&b).ok()) .filter(|o| o.schema_hash == schema) .filter_map(|o| build(&o)) .collect() } // ── Weapon ──────────────────────────────────────────────────────────────────── /// A weapon definition (schema [`schema::WEAPON`]). #[derive(Debug, Clone)] pub struct Weapon { pub id: Option, pub name: Option, /// Target mask, e.g. `"Vessel,Craft,Structure"`. pub target_type: 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 life_time: Option, /// All explicitly-set fields (a superset of the named ones above). pub fields: BTreeMap, } impl Weapon { fn from_idxd(o: &IdxdObject) -> Option { let f = field_map(o); 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, }) } /// 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 (e.g. `GP_MAIN_GAME_E.pak`). pub fn load_weapons(pak: &PakArchive) -> Vec { load_table(pak, schema::WEAPON, Weapon::from_idxd) } // ── Craft / unit ──────────────────────────────────────────────────────────────── /// A craft / unit — fighter, bomber, turret (schema [`schema::UNIT`]). Carries the /// AI flight model in [`fields`](CraftUnit::fields) (`AV_Pitch*`, `BarrelRoll_*`, /// `TurnAttack_*`, …). #[derive(Debug, Clone)] pub struct CraftUnit { pub id: Option, pub name: Option, 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 cruising_velocity: Option, pub maximum_velocity: Option, pub acceleration: Option, pub deceleration: Option, pub shield_ratio: Option, pub turret_count: Option, pub score_point: Option, pub fields: BTreeMap, } impl CraftUnit { fn from_idxd(o: &IdxdObject) -> Option { let f = field_map(o); 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, }) } pub fn field_f32(&self, key: &str) -> Option { as_f32(self.fields.get(key)) } } /// Load every craft / unit from a pak. 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. #[derive(Debug, Clone)] pub struct Vessel { pub id: Option, pub name: Option, pub hp: Option, pub size_x: Option, pub size_y: Option, pub size_z: Option, pub radar_range: Option, pub fcs_range: Option, pub maximum_velocity: Option, pub shield_ratio: Option, pub score_point: 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, } impl Vessel { fn from_idxd(o: &IdxdObject) -> Option { let f = field_map(o); Some(Vessel { id: o.get_raw("ID").map(str::to_string), name: o.get_raw("Name").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, }) } pub fn field_f32(&self, key: &str) -> Option { as_f32(self.fields.get(key)) } } /// Load every capital ship from a pak. pub fn load_vessels(pak: &PakArchive) -> Vec { load_table(pak, schema::VESSEL, Vessel::from_idxd) } // ── 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). #[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 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, } impl PlayerConfig { fn from_idxd(o: &IdxdObject) -> Option { let f = field_map(o); 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, }) } pub fn field_f32(&self, key: &str) -> Option { as_f32(self.fields.get(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 (schema [`CHARACTER`](schema::CHARACTER)) — pilots, crew, comms. #[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: each emotion face and the texture that draws it. pub faces: Vec, pub fields: BTreeMap, } 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(), }); } } } 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"), faces, fields: field_map(o), }) } } /// Load every character from a pak. pub fn load_characters(pak: &PakArchive) -> Vec { load_table(pak, schema::CHARACTER, Character::from_idxd) } // ── 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()) } /// 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. #[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, 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, /// Every `resource-kind → table` reference in the manifest. pub resources: BTreeMap, } 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 id = unit_table .as_deref() .and_then(|s| s.strip_prefix("EnumUnit_")) .map(|s| s.trim_end_matches(".tbl").to_string()) .unwrap_or_default(); Some(Stage { id, location: o.get_raw("BackGroundID").map(str::to_string), phases: t.iter().filter(|s| s.starts_with("Phase_")).count() as u32, 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), resources, }) } } /// 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 ───────────────────────────────────────────────────── /// 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`. #[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, pub formation_id: Option, pub ai_id: Option, /// Faction — `TCAF` (allied) or `ADAN` (enemy). pub side: Option, pub count: Option, /// Member craft (`UN_*` unit ids). pub members: Vec, } /// Load every squadron from a pak. Scans all `Enumerate_Squadrons` tables (their /// schema hash varies per stage, but the table name is stable). 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")) { 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; 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(), }); } } out } // ── 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. #[derive(Debug, Clone)] pub struct DemoMessage { /// Line id, e.g. `MSG_VOICE_A_007`. pub id: String, /// Speaker (`CharacterRAYMOND`) when the line names one; many system / /// continuation lines are unattributed (`None`). pub character: Option, /// Portrait / emotion (`FaceRAYMOND_07`). pub face: Option, /// Voice clip token (`VOICE_A_007`). pub voice_clip: Option, /// Caption page text keys (`MSG_VOICE_A_007_000_00`, …) — resolve for the text. 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. 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 } // ── Generic record access (any of the ~105 schemas) ───────────────────────────── /// 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, …). #[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. pub table: String, pub schema: u32, pub id: Option, pub name: Option, /// Every explicitly-set field (`key → value`). pub fields: BTreeMap, } impl Record { fn from_idxd(o: &IdxdObject) -> Option { Some(Record { table: o.tokens().first().cloned().unwrap_or_default(), 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), }) } 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 /// 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".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); } // 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!(ws.len() >= 100, "expected ≥100 weapons, got {}", ws.len()); let rocket = ws .iter() .find(|w| w.id.as_deref().is_some_and(|s| s.contains("DeltaSaber_Rocket"))) .expect("Delta Saber rocket present"); assert_eq!(rocket.velocity, Some(3000.0)); assert_eq!(rocket.max_range, Some(6000.0)); assert_eq!(rocket.loading_count, Some(1000)); assert_eq!(rocket.target_type.as_deref(), Some("Vessel,Craft,Structure")); } #[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. let player = units .iter() .find(|u| u.id.as_deref().is_some_and(|s| s.contains("DeltaSaber_T"))) .expect("player craft present"); assert_eq!(player.hp, Some(1000.0)); assert_eq!(player.radar_range, Some(500000.0)); let vessels = load_vessels(&pak); let flagship = vessels .iter() .find(|v| v.id.as_deref().is_some_and(|s| s.contains("SDBattleship"))) .expect("SD-Battleship present"); assert_eq!(flagship.hp, Some(100000.0)); assert_eq!(flagship.turret_count, Some(25)); } #[test] fn loads_player_configs() { let Some(pak) = pak() else { return }; let cfgs = load_player_configs(&pak); assert!(!cfgs.is_empty()); 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)); } #[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()); 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!(raymond.faces.iter().all(|f| f.texture.ends_with(".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.phases, 3); assert_eq!(s01.unit_table.as_deref(), Some("EnumUnit_S01.tbl")); assert_eq!(s01.squadron_table.as_deref(), Some("UnitGroup_S01.tbl")); // 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!(sq.len() >= 20, "expected many squadrons, got {}", sq.len()); // A TCAF Rhino flight of Delta Sabers, membership matching its count. 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")) }) .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"))); } #[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))); } #[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"))); } }