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
1677 lines
67 KiB
Rust
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"));
|
|
}
|
|
}
|