Files
Sylpheed/crates/sylpheed-formats/src/game_data.rs
Sylpheed RE agent 6ce1a87d49 re: rebuild game_data on the IDXD record table — 966 misses and 596 flattened reads
Measured first, over GP_MAIN_GAME_E.pak, comparing every named-field read the
six struct loaders performed against the record table: 4435 reads, 2872 agreed,
**966 returned None for a field that has a value**, **596 flattened a field that
several records carry**, 1 was wrong (a weapon whose TargetType is the empty
string read back as the neighbouring token "Skip"). The prior report of
4453/2887/974/591/1 is the same picture; the small differences are definitional
(I count a read as flattened only when the records disagree).

Every read now goes through IdxdObject::record, and the types say where a value
comes from:

* Weapon = the `Weapon` record (launcher) + the `Shell` record (projectile).
  Both carry an ID and a Name and — with `ShellWake` — an `Interval`, which the
  flat reader merged; they are separate fields now. Power/Velocity/ranges/
  LifeTime are Shell fields, which is why 427 weapon reads used to miss.
* CraftUnit/Vessel = `Generic` (hull) + `Maneuver` (flight model) +
  `StructureCount` (counts) + `Shield`, plus a new `hardpoints: Vec<Hardpoint>`
  — one entry per Turret_/Bridge_/Thruster_/Hatch_/ShieldGenerator_ record, each
  with its own HP. A flat HP could only ever be one of them.
* PlayerConfig = `Player`, plus `phases: Vec<PlayerPhase>` (SpaceSize/SupplyRange
  are per Phase_N) and `score: ByDifficulty<ScoreRules>` (MainMissionBonus is per
  Score_<difficulty>; the flat answer was the Easy one).
* Character faces come from the `Faces` record's field names (identical output to
  the old token scrape, 0 of 68 objects differ — now by construction).
* Stage = `StageResource` + `phases: Vec<StagePhase>`, and the packages it names.
* The `fields: BTreeMap` on every struct became `records: RecordSet`, which keeps
  the record boundary; `RecordSet::everywhere(field)` answers "which record".

The token-scraping loaders move too, and this is where the old reader was worst:

* Arsenal: options are the positional fields of the STANDARD_<slot> records. The
  scrape returned 16 nose options of which 8 were field keys and pilot names, and
  47 for arm3 of which 38 were junk, while missing Mine_B2A and No_Equipment.
  Now 8/12/9/9, all real weapons.
* Squadron: one record per squadron, members are Count*4 positional slots
  (unit, message set, n, pilot) — 1160 squadrons with ids and 2295 member tuples,
  against 28 idless squadrons and 47 members before. Agrees exactly with the
  independent Python decode in docs/re/structures/unit-group-table.md.
* DemoMessage: 11775 lines against 10263, every one with a speaker, a portrait,
  a delivery mode and a voice token, from fixed positional slots.
* PilotRoster: assignments are the records the `UNITS` record names, so each one
  now carries its unit id, its loadout and the player marker.
* UnitRoster: the roster is the field *names* of the single `EnumUnit` record.
* load_weapons selects on the records (Weapon + Shell) rather than on token[0],
  whose first byte is often a stray pool byte ("#Weapon", "%Weapon"). Same 131
  objects, no heuristic. GP_HANGAR_ARSENAL.pak holds none of them — the module
  doc's claim that player weapons live there was wrong.

schema:: constants keep their names and values but are documented for what they
are: record 0's name hash (PLAYER = Difficulty_Easy, UNIT = Maneuver, VESSEL =
Bridge_000, MESSAGE = Message_000), not a schema id.

Two things the migration exposes and does not fix, flagged in the docs instead:
load_units' bucket is 43 Type=Craft + 46 Type=Vessel objects (new `unit_type`
field lets a caller separate them), and StructureCount.TurretCount is not the
number of Turret_* records (the player's craft says 4 and has 63).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 01:42:53 +00:00

1677 lines
67 KiB
Rust

//! 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<f32> {
let v = v?;
v.strip_suffix(['f', 'F']).unwrap_or(v).parse().ok()
}
fn as_i64(v: Option<&str>) -> Option<i64> {
v?.parse().ok()
}
fn as_bool(v: Option<&str>) -> Option<bool> {
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<String> {
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<String, String>,
/// `(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<String> {
text(self.get(field))
}
pub fn f32(&self, field: &str) -> Option<f32> {
as_f32(self.get(field))
}
pub fn i64(&self, field: &str) -> Option<i64> {
as_i64(self.get(field))
}
pub fn bool(&self, field: &str) -> Option<bool> {
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<Item = &str> {
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<String, RecordFields>,
/// Record names in on-disc (name-hash-ascending) order.
order: Vec<String>,
}
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<String> {
self.record(record)?.text(field)
}
pub fn f32(&self, record: &str, field: &str) -> Option<f32> {
self.record(record)?.f32(field)
}
pub fn i64(&self, record: &str, field: &str) -> Option<i64> {
self.record(record)?.i64(field)
}
pub fn bool(&self, record: &str, field: &str) -> Option<bool> {
self.record(record)?.bool(field)
}
/// Record names, in on-disc order.
pub fn names(&self) -> impl Iterator<Item = &str> {
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<Item = (&'a str, &'a RecordFields)> {
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<T>(
pak: &PakArchive,
keep: impl Fn(&IdxdObject) -> bool,
build: impl Fn(&IdxdObject) -> Option<T>,
) -> Vec<T> {
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<T>(pak: &PakArchive, schema: u32, build: impl Fn(&IdxdObject) -> Option<T>) -> Vec<T> {
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<String>,
pub name: Option<String>,
pub hp: Option<f32>,
pub is_destructible: Option<bool>,
pub is_shielded: Option<bool>,
pub radius: Option<f32>,
/// Damage dealt to the parent hull when this component blows up.
pub spread_damage: Option<f32>,
/// Intact model (`NomalModel` on disc — the game's own spelling).
pub model: Option<String>,
pub collision_model: Option<String>,
/// Attachment frame in the parent's scene graph, e.g. `GN_GunL_05_ContH`.
pub frame: Option<String>,
/// Turrets: the weapon they fire.
pub weapon_id: Option<String>,
/// Bridges / thrusters / shield generators: share of the parent's capability.
pub power_ratio: Option<f32>,
/// Hatches: the squadron this bay launches.
pub squadron_id: Option<String>,
}
/// Every `<Kind>_NNN` record of an object, ordered by kind then index.
fn hardpoints(rs: &RecordSet) -> Vec<Hardpoint> {
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::<u32>() 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<String>,
/// `Weapon.Name` — a localization key (`WeaponCannonName_…`).
pub name: Option<String>,
/// Target mask, e.g. `"Vessel,Craft,Structure"`.
pub target_type: Option<String>,
/// Seconds between shots (`Weapon.Interval`).
pub interval: Option<f32>,
/// Ammo / reload budget (`Weapon.LoadingCount`).
pub loading_count: Option<i64>,
/// Shots per trigger pull (volley / lock count).
pub trigger_shot_count: Option<i64>,
/// Seconds between the shots of one volley.
pub trigger_shot_interval: Option<f32>,
/// Launcher mass (`Weapon.Mass`) — the projectile's own is `Shell.ShellMass`.
pub mass: Option<f32>,
pub heating: Option<f32>,
pub cooling: Option<f32>,
/// Firing pattern, e.g. `Single`, `Burst`.
pub shot_type: Option<String>,
/// `Shell.ID`, e.g. `Shell_TCAF_DeltaSaber_Rocket_P`.
pub shell_id: Option<String>,
/// `Shell.Name` — a localization key (`WeaponShellName_…`).
pub shell_name: Option<String>,
pub power: Option<f32>,
pub velocity: Option<f32>,
pub min_velocity: Option<f32>,
pub max_velocity: Option<f32>,
pub min_range: Option<f32>,
pub max_range: Option<f32>,
pub acceleration: Option<f32>,
pub life_time: Option<f32>,
/// `Shell`, `Laser`, `Missile`, … — how the projectile is simulated.
pub movement_type: Option<String>,
pub damage_type: Option<String>,
/// Every record of the source object (`Weapon`, `Shell`, `ShellWake`,
/// `WhiskMissileParam`, `AssortMissileParam`, `BezierMissileParam`).
pub records: RecordSet,
}
impl Weapon {
fn from_idxd(o: &IdxdObject) -> Option<Self> {
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<Weapon> {
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<String>,
pub name: Option<String>,
/// `Generic.Type` — `Craft` or `Vessel`, the game's own classification.
pub unit_type: Option<String>,
/// The 3D hull resource stem, e.g. `rou_f001`.
pub model: Option<String>,
/// Hull hit points (`Generic.HP`) — each [`Hardpoint`] has its own.
pub hp: Option<f32>,
pub is_destructible: Option<bool>,
pub size_x: Option<f32>,
pub size_y: Option<f32>,
pub size_z: Option<f32>,
pub size_radius: Option<f32>,
pub radar_range: Option<f32>,
pub fcs_range: Option<f32>,
pub score_point: Option<i64>,
pub shield_ratio: Option<f32>,
/// `Shield.MaxValue` — the rechargeable shield pool.
pub shield_max: Option<f32>,
pub cruising_velocity: Option<f32>,
pub minimum_velocity: Option<f32>,
pub maximum_velocity: Option<f32>,
pub acceleration: Option<f32>,
/// `Maneuver.Deceleration`. The flat reader answered `NS_Body`'s
/// trail-particle deceleration for the 44 units that have one.
pub deceleration: Option<f32>,
/// `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<i64>,
pub bridge_count: Option<i64>,
pub hatch_count: Option<i64>,
pub shield_generator_count: Option<i64>,
pub thruster_count: Option<i64>,
/// Every destructible component, each with its own stats.
pub hardpoints: Vec<Hardpoint>,
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<String>,
name: Option<String>,
unit_type: Option<String>,
model: Option<String>,
hp: Option<f32>,
is_destructible: Option<bool>,
size_x: Option<f32>,
size_y: Option<f32>,
size_z: Option<f32>,
size_radius: Option<f32>,
radar_range: Option<f32>,
fcs_range: Option<f32>,
score_point: Option<i64>,
shield_ratio: Option<f32>,
shield_max: Option<f32>,
cruising_velocity: Option<f32>,
minimum_velocity: Option<f32>,
maximum_velocity: Option<f32>,
acceleration: Option<f32>,
deceleration: Option<f32>,
turret_count: Option<i64>,
bridge_count: Option<i64>,
hatch_count: Option<i64>,
shield_generator_count: Option<i64>,
thruster_count: Option<i64>,
}
impl Hull {
fn read(records: &RecordSet) -> Option<Hull> {
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<Self> {
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<f32> {
self.records.f32("Maneuver", key)
}
}
/// Load every craft / unit from a pak (see the caveat on [`CraftUnit`]).
pub fn load_units(pak: &PakArchive) -> Vec<CraftUnit> {
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<String>,
pub name: Option<String>,
/// `Generic.Type` — `Vessel` for all 23 objects on the retail disc.
pub unit_type: Option<String>,
/// 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<String>,
/// Hull hit points. Each [`Hardpoint`] carries its own.
pub hp: Option<f32>,
pub is_destructible: Option<bool>,
pub size_x: Option<f32>,
pub size_y: Option<f32>,
pub size_z: Option<f32>,
pub size_radius: Option<f32>,
pub radar_range: Option<f32>,
pub fcs_range: Option<f32>,
pub score_point: Option<i64>,
pub shield_ratio: Option<f32>,
pub shield_max: Option<f32>,
pub cruising_velocity: Option<f32>,
pub maximum_velocity: Option<f32>,
pub acceleration: Option<f32>,
pub deceleration: Option<f32>,
pub turret_count: Option<i64>,
pub bridge_count: Option<i64>,
pub hatch_count: Option<i64>,
pub shield_generator_count: Option<i64>,
pub thruster_count: Option<i64>,
/// Every destructible component, each with its own `HP`.
pub hardpoints: Vec<Hardpoint>,
pub records: RecordSet,
}
impl Vessel {
fn from_idxd(o: &IdxdObject) -> Option<Self> {
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<Vessel> {
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<T> {
pub easy: T,
pub normal: T,
pub hard: T,
}
impl<T> ByDifficulty<T> {
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<Item = (Difficulty, &T)> {
[
(Difficulty::Easy, &self.easy),
(Difficulty::Normal, &self.normal),
(Difficulty::Hard, &self.hard),
]
.into_iter()
}
}
/// The scoring rules of one difficulty (record `Score_<Difficulty>`).
#[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<i64>,
/// Number of main objectives the bonus is paid for.
pub main_mission_count: Option<i64>,
/// 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<i64>,
pub rank_score_a: Option<i64>,
pub rank_score_b: Option<i64>,
pub rank_score_c: Option<i64>,
pub rank_score_d: Option<i64>,
pub lost_wingman_penalty: Option<i64>,
pub kill_bonus_maximum: Option<i64>,
}
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<f32>,
/// Range at which a resupply ship can be docked with.
pub supply_range: Option<f32>,
/// The squadron the player commands in this phase.
pub under_command_squadron: Option<String>,
pub supply_squadron_1: Option<String>,
pub supply_squadron_2: Option<String>,
}
/// 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<f32>,
pub gravity_factor: Option<f32>,
pub bullet_limit: Option<i64>,
pub laser_limit: Option<i64>,
pub homing_limit: Option<i64>,
pub pitch_adjustment: Option<f32>,
pub yaw_adjustment: Option<f32>,
pub roll_adjustment: Option<f32>,
/// `Phase_1`, `Phase_2`, … in order.
pub phases: Vec<PlayerPhase>,
/// `Score_Easy` / `Score_Normal` / `Score_Hard`.
pub score: ByDifficulty<ScoreRules>,
pub records: RecordSet,
}
impl PlayerConfig {
fn from_idxd(o: &IdxdObject) -> Option<Self> {
let records = RecordSet::from_idxd(o);
let p = records.record("Player")?;
let mut phases: Vec<PlayerPhase> = 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<f32> {
self.phases.iter().find(|p| p.phase == phase)?.space_size
}
/// A damage/guidance multiplier from the `Difficulty_<level>` record
/// (`DamageAdjustment`, `ShieldDamageAdjustment`, `FriendlyFireAdjustment`, …).
pub fn difficulty_f32(&self, d: Difficulty, key: &str) -> Option<f32> {
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<PlayerConfig> {
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<String>,
/// Localized display-name key (resolve against the game's string tables).
pub name_key: Option<String>,
/// Faction / side, e.g. `TCAF` (player) or `ADAN` (enemy).
pub faction: Option<String>,
pub unique: Option<bool>,
/// Portrait set, ordered by emotion id.
pub faces: Vec<Face>,
pub records: RecordSet,
}
impl Character {
fn from_idxd(o: &IdxdObject) -> Option<Self> {
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<Character> {
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<String>,
/// Collision mesh, e.g. `s01_p1.col`.
pub map_mesh: Option<String>,
/// Asteroid-field definition table, when the phase has one.
pub asteroid_definition: Option<String>,
pub background_resource_id: Option<String>,
}
/// 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<NN>.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<String>,
/// 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<StagePhase>,
pub unit_table: Option<String>,
pub character_table: Option<String>,
pub squadron_table: Option<String>,
pub formation_table: Option<String>,
pub route_table: Option<String>,
pub ai_params_table: Option<String>,
pub subobjective_table: Option<String>,
pub message_table: Option<String>,
/// The 3D package holding the stage's ships, e.g.
/// `game:\hidden\Resource3D\Stage_S02.xpr`.
pub stage_package: Option<String>,
/// The 3D package holding the skybox / backdrop.
pub background_package: Option<String>,
/// Every `resource-kind → table` reference in the manifest.
pub resources: BTreeMap<String, String>,
pub records: RecordSet,
}
impl Stage {
fn from_idxd(o: &IdxdObject) -> Option<Self> {
let records = RecordSet::from_idxd(o);
let sr = records.record("StageResource")?;
let resources: BTreeMap<String, String> = 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<StagePhase> = 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<Stage> {
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<String>,
/// Pilot / character id, e.g. `ELLEN`, `Character_Player_Test`.
pub pilot: Option<String>,
/// 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<i64>,
}
/// 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<String>,
pub ai_id: Option<String>,
/// Faction — `TCAF` (allied) or `ADAN` (enemy).
pub side: Option<String>,
/// Declared member count; `members.len()` is derived from the same number.
pub count: Option<i64>,
pub disable_interval: Option<bool>,
pub members: Vec<SquadronMember>,
/// 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<NN>.tbl` tables).
pub fn load_squadrons(pak: &PakArchive) -> Vec<Squadron> {
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<String>,
/// Portrait / emotion (`FaceRAYMOND_07`) — slot 1.
pub face: Option<String>,
/// ❔ 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<String>,
/// Slot 3 — a duration in seconds.
pub duration: Option<f32>,
/// 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<String>,
/// Declared caption-page count.
pub page_count: Option<i64>,
/// Caption page text keys (`MSG_VOICE_A_007_000_00`, …) — slots 5 and up.
pub page_keys: Vec<String>,
}
/// Load every dialogue line from a pak. One [`DemoMessage`] per `Message_NNN`
/// record.
pub fn load_demo_messages(pak: &PakArchive) -> Vec<DemoMessage> {
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::<Vec<_>>()
})
.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<NN>` 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<NN>_`-prefixed prop id and is `None` when none is present.
#[derive(Debug, Clone)]
pub struct UnitRoster {
pub stage: Option<String>,
/// Distinct combatant unit ids (`UN_*`), excluding stage props.
pub units: Vec<String>,
/// The stage props filtered out of [`units`](Self::units) — asteroids,
/// collision meshes, boxes.
pub props: Vec<String>,
}
/// Load every mission unit roster from a pak.
pub fn load_unit_rosters(pak: &PakArchive) -> Vec<UnitRoster> {
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 `<Callsign>-<Pilot>` 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<String>,
/// Loadout-set record names (`S01SET_NOSE`, `PlayerSET_ARM1`, …); resolve
/// against the same object's records for the weapons.
pub nose: Option<String>,
pub arm1: Option<String>,
pub arm2: Option<String>,
pub arm3: Option<String>,
/// 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<FlightAssignment>,
/// The player craft unit id (`UN_f001_TCAF_DeltaSaber_T_Player`), from the
/// assignment carrying the `PlayerUnit` marker.
pub player_unit: Option<String>,
}
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<PilotRoster> {
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_<slot>` 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<String>,
/// The three arm hardpoints — missile / bomb / special options.
pub arm1: Vec<String>,
pub arm2: Vec<String>,
pub arm3: Vec<String>,
}
/// 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<String>,
/// The first non-empty `Name` in record order.
pub name: Option<String>,
pub records: RecordSet,
}
impl Record {
fn from_idxd(o: &IdxdObject) -> Option<Self> {
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<Record> {
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<PakArchive> {
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"));
}
}