Decode the message tables (schema b412e6d8) into dialogue lines: each `Message_NNN` sub-record → a DemoMessage with the speaker, portrait (face), voice-clip token, and caption page text-keys. Fields are found by prefix (Character*/Face*/VOICE_*/id-prefixed pages), so the positional / first-record-defines-schema layout doesn't matter. 10,263 lines parse; ~815 name an explicit speaker (the rest are system / continuation lines — left unattributed rather than guessed). Paired with localization::TextIndex the attributed ones read straight out — e.g. KATANA [VOICE_RHIN_000] "Ellen, get back into formation!". The voice-clip token ties each line to its audio bank, closing the loop with the cutscene-voice work. +1 test; example `dialogue` prints speaker + clip + resolved text. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
755 lines
31 KiB
Rust
755 lines
31 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, `token[0]` naming the table, fields laid out value-before-key.
|
|
//! This module maps the four combat schemas to plain, cloneable structs: the
|
|
//! common stats as named `Option<…>` fields, and **every** explicitly-set field in
|
|
//! a [`fields`](Weapon::fields) map so nothing is lost.
|
|
//!
|
|
//! Reverse-engineered 2026-07-23 from `GP_MAIN_GAME_E.pak` (English; the D/F/I/J/S
|
|
//! paks are localized duplicates). Fields left at their default value omit their
|
|
//! value on disc — those live in title code, not here — so a `None` means "not set
|
|
//! on disc", never a guess.
|
|
//!
|
|
//! ```no_run
|
|
//! use sylpheed_formats::{game_data, PakArchive};
|
|
//! let pak = PakArchive::open("dat/GP_MAIN_GAME_E.pak").unwrap();
|
|
//! for w in game_data::load_weapons(&pak) {
|
|
//! println!("{} — power {:?}, range {:?}", w.id.unwrap_or_default(), w.power, w.max_range);
|
|
//! }
|
|
//! ```
|
|
|
|
use crate::idxd::IdxdObject;
|
|
use crate::pak::PakArchive;
|
|
use std::collections::BTreeMap;
|
|
|
|
/// IDXD schema ids of the combat tables (the `schema_hash` field of each blob).
|
|
pub mod schema {
|
|
/// Player craft config: physics, cameras, scoring, difficulty, render pipeline.
|
|
pub const PLAYER: u32 = 0x0426_e81d;
|
|
/// Weapon definitions (guns, missiles, beams, bombs).
|
|
pub const WEAPON: u32 = 0x6ab4_825a;
|
|
/// Craft / units — fighters, bombers, turrets, with their AI flight model.
|
|
pub const UNIT: u32 = 0x43fa_a517;
|
|
/// Vessels — capital ships, with structural component counts.
|
|
pub const VESSEL: u32 = 0x3c5b_0549;
|
|
/// Characters — pilots / crew / comms, with faction and portrait set.
|
|
pub const CHARACTER: u32 = 0xbd86_d41c;
|
|
/// Stage resource manifest — per mission: location, phases, resource tables.
|
|
pub const STAGE: u32 = 0x3c9a_e32e;
|
|
/// Message / demo dialogue — per line: speaker, portrait, voice clip, pages.
|
|
pub const MESSAGE: u32 = 0xb412_e6d8;
|
|
}
|
|
|
|
/// Parse a numeric field value, tolerating a trailing `f`/`F` (e.g. `"3.0f"`).
|
|
fn as_f32(v: Option<&String>) -> Option<f32> {
|
|
let v = v?;
|
|
let v = v.strip_suffix(['f', 'F']).unwrap_or(v);
|
|
v.parse().ok()
|
|
}
|
|
|
|
fn as_i64(v: Option<&String>) -> Option<i64> {
|
|
v?.parse().ok()
|
|
}
|
|
|
|
/// Collect every explicitly-set field of an IDXD blob into a `key → value` map,
|
|
/// plus the identity strings. Identifier-valued fields (`Model`, `ShotType`, …)
|
|
/// that carry a bare name rather than a value are not in the map — read those from
|
|
/// [`IdxdObject::get_raw`] if needed.
|
|
fn field_map(o: &IdxdObject) -> BTreeMap<String, String> {
|
|
o.resolved_fields()
|
|
.into_iter()
|
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
.collect()
|
|
}
|
|
|
|
/// Read every record of `schema` from a pak, mapping each with `build`.
|
|
fn load_table<T>(pak: &PakArchive, schema: u32, 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| o.schema_hash == schema)
|
|
.filter_map(|o| build(&o))
|
|
.collect()
|
|
}
|
|
|
|
// ── Weapon ────────────────────────────────────────────────────────────────────
|
|
|
|
/// A weapon definition (schema [`schema::WEAPON`]).
|
|
#[derive(Debug, Clone)]
|
|
pub struct Weapon {
|
|
pub id: Option<String>,
|
|
pub name: Option<String>,
|
|
/// Target mask, e.g. `"Vessel,Craft,Structure"`.
|
|
pub target_type: 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>,
|
|
/// Ammo / reload budget (`LoadingCount`).
|
|
pub loading_count: Option<i64>,
|
|
/// Seconds between shots.
|
|
pub interval: Option<f32>,
|
|
/// Shots per trigger pull (volley / lock count).
|
|
pub trigger_shot_count: Option<i64>,
|
|
pub mass: Option<f32>,
|
|
pub heating: Option<f32>,
|
|
pub cooling: Option<f32>,
|
|
pub life_time: Option<f32>,
|
|
/// All explicitly-set fields (a superset of the named ones above).
|
|
pub fields: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl Weapon {
|
|
fn from_idxd(o: &IdxdObject) -> Option<Self> {
|
|
let f = field_map(o);
|
|
Some(Weapon {
|
|
id: o.get_raw("ID").map(str::to_string),
|
|
name: o.get_raw("Name").map(str::to_string),
|
|
target_type: o.get_raw("TargetType").map(str::to_string),
|
|
power: as_f32(f.get("Power")),
|
|
velocity: as_f32(f.get("Velocity")),
|
|
min_velocity: as_f32(f.get("MinimumVelocity")),
|
|
max_velocity: as_f32(f.get("MaximumVelocity")),
|
|
min_range: as_f32(f.get("MinimumRange")),
|
|
max_range: as_f32(f.get("MaximumRange")),
|
|
loading_count: as_i64(f.get("LoadingCount")),
|
|
interval: as_f32(f.get("Interval")),
|
|
trigger_shot_count: as_i64(f.get("TriggerShotCount")),
|
|
mass: as_f32(f.get("Mass")),
|
|
heating: as_f32(f.get("Heating")),
|
|
cooling: as_f32(f.get("Cooling")),
|
|
life_time: as_f32(f.get("LifeTime")),
|
|
fields: f,
|
|
})
|
|
}
|
|
/// Any explicit field parsed as `f32` (for the long-tail stats not named above).
|
|
pub fn field_f32(&self, key: &str) -> Option<f32> {
|
|
as_f32(self.fields.get(key))
|
|
}
|
|
}
|
|
|
|
/// Load every weapon from a pak (e.g. `GP_MAIN_GAME_E.pak`).
|
|
pub fn load_weapons(pak: &PakArchive) -> Vec<Weapon> {
|
|
load_table(pak, schema::WEAPON, Weapon::from_idxd)
|
|
}
|
|
|
|
// ── Craft / unit ────────────────────────────────────────────────────────────────
|
|
|
|
/// A craft / unit — fighter, bomber, turret (schema [`schema::UNIT`]). Carries the
|
|
/// AI flight model in [`fields`](CraftUnit::fields) (`AV_Pitch*`, `BarrelRoll_*`,
|
|
/// `TurnAttack_*`, …).
|
|
#[derive(Debug, Clone)]
|
|
pub struct CraftUnit {
|
|
pub id: Option<String>,
|
|
pub name: Option<String>,
|
|
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 cruising_velocity: Option<f32>,
|
|
pub maximum_velocity: Option<f32>,
|
|
pub acceleration: Option<f32>,
|
|
pub deceleration: Option<f32>,
|
|
pub shield_ratio: Option<f32>,
|
|
pub turret_count: Option<i64>,
|
|
pub score_point: Option<i64>,
|
|
pub fields: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl CraftUnit {
|
|
fn from_idxd(o: &IdxdObject) -> Option<Self> {
|
|
let f = field_map(o);
|
|
Some(CraftUnit {
|
|
id: o.get_raw("ID").map(str::to_string),
|
|
name: o.get_raw("Name").map(str::to_string),
|
|
hp: as_f32(f.get("HP")),
|
|
is_destructible: o.get_bool("IsDestructible"),
|
|
size_x: as_f32(f.get("Size_X")),
|
|
size_y: as_f32(f.get("Size_Y")),
|
|
size_z: as_f32(f.get("Size_Z")),
|
|
size_radius: as_f32(f.get("Size_Radius")),
|
|
radar_range: as_f32(f.get("RadarRange")),
|
|
fcs_range: as_f32(f.get("FCSRange")),
|
|
cruising_velocity: as_f32(f.get("CruisingVelocity")),
|
|
maximum_velocity: as_f32(f.get("MaximumVelocity")),
|
|
acceleration: as_f32(f.get("Acceleration")),
|
|
deceleration: as_f32(f.get("Deceleration")),
|
|
shield_ratio: as_f32(f.get("ShieldRatio")),
|
|
turret_count: as_i64(f.get("TurretCount")),
|
|
score_point: as_i64(f.get("ScorePoint")),
|
|
fields: f,
|
|
})
|
|
}
|
|
pub fn field_f32(&self, key: &str) -> Option<f32> {
|
|
as_f32(self.fields.get(key))
|
|
}
|
|
}
|
|
|
|
/// Load every craft / unit from a pak.
|
|
pub fn load_units(pak: &PakArchive) -> Vec<CraftUnit> {
|
|
load_table(pak, schema::UNIT, CraftUnit::from_idxd)
|
|
}
|
|
|
|
// ── Vessel / capital ship ───────────────────────────────────────────────────────
|
|
|
|
/// A capital ship (schema [`schema::VESSEL`]). Component counts drive the
|
|
/// destructible hardpoints.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Vessel {
|
|
pub id: Option<String>,
|
|
pub name: Option<String>,
|
|
pub hp: Option<f32>,
|
|
pub size_x: Option<f32>,
|
|
pub size_y: Option<f32>,
|
|
pub size_z: Option<f32>,
|
|
pub radar_range: Option<f32>,
|
|
pub fcs_range: Option<f32>,
|
|
pub maximum_velocity: Option<f32>,
|
|
pub shield_ratio: Option<f32>,
|
|
pub score_point: Option<i64>,
|
|
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>,
|
|
pub fields: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl Vessel {
|
|
fn from_idxd(o: &IdxdObject) -> Option<Self> {
|
|
let f = field_map(o);
|
|
Some(Vessel {
|
|
id: o.get_raw("ID").map(str::to_string),
|
|
name: o.get_raw("Name").map(str::to_string),
|
|
hp: as_f32(f.get("HP")),
|
|
size_x: as_f32(f.get("Size_X")),
|
|
size_y: as_f32(f.get("Size_Y")),
|
|
size_z: as_f32(f.get("Size_Z")),
|
|
radar_range: as_f32(f.get("RadarRange")),
|
|
fcs_range: as_f32(f.get("FCSRange")),
|
|
maximum_velocity: as_f32(f.get("MaximumVelocity")),
|
|
shield_ratio: as_f32(f.get("ShieldRatio")),
|
|
score_point: as_i64(f.get("ScorePoint")),
|
|
turret_count: as_i64(f.get("TurretCount")),
|
|
bridge_count: as_i64(f.get("BridgeCount")),
|
|
hatch_count: as_i64(f.get("HatchCount")),
|
|
shield_generator_count: as_i64(f.get("ShieldGeneratorCount")),
|
|
thruster_count: as_i64(f.get("ThrusterCount")),
|
|
fields: f,
|
|
})
|
|
}
|
|
pub fn field_f32(&self, key: &str) -> Option<f32> {
|
|
as_f32(self.fields.get(key))
|
|
}
|
|
}
|
|
|
|
/// 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 ───────────────────────────────────────────────────────────────
|
|
|
|
/// Player craft config (schema [`schema::PLAYER`]) — one per mission. Physics,
|
|
/// projectile caps and scoring here; cameras, difficulty and the render pipeline
|
|
/// live in [`fields`](PlayerConfig::fields).
|
|
#[derive(Debug, Clone)]
|
|
pub struct PlayerConfig {
|
|
pub air_drag_factor: Option<f32>,
|
|
pub gravity_factor: Option<f32>,
|
|
pub bullet_limit: Option<i64>,
|
|
pub laser_limit: Option<i64>,
|
|
pub homing_limit: Option<i64>,
|
|
pub space_size: Option<f32>,
|
|
pub supply_range: Option<f32>,
|
|
pub main_mission_bonus: Option<i64>,
|
|
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 fields: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl PlayerConfig {
|
|
fn from_idxd(o: &IdxdObject) -> Option<Self> {
|
|
let f = field_map(o);
|
|
Some(PlayerConfig {
|
|
air_drag_factor: as_f32(f.get("AirDragFactor")),
|
|
gravity_factor: as_f32(f.get("GravityFactor")),
|
|
bullet_limit: as_i64(f.get("BulletLimit")),
|
|
laser_limit: as_i64(f.get("LaserLimit")),
|
|
homing_limit: as_i64(f.get("HomingLimit")),
|
|
space_size: as_f32(f.get("SpaceSize")),
|
|
supply_range: as_f32(f.get("SupplyRange")),
|
|
main_mission_bonus: as_i64(f.get("MainMissionBonus")),
|
|
rank_score_s: as_i64(f.get("RankScore_S")),
|
|
rank_score_a: as_i64(f.get("RankScore_A")),
|
|
rank_score_b: as_i64(f.get("RankScore_B")),
|
|
rank_score_c: as_i64(f.get("RankScore_C")),
|
|
rank_score_d: as_i64(f.get("RankScore_D")),
|
|
fields: f,
|
|
})
|
|
}
|
|
pub fn field_f32(&self, key: &str) -> Option<f32> {
|
|
as_f32(self.fields.get(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 (schema [`CHARACTER`](schema::CHARACTER)) — pilots, crew, comms.
|
|
#[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: each emotion face and the texture that draws it.
|
|
pub faces: Vec<Face>,
|
|
pub fields: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl Character {
|
|
fn from_idxd(o: &IdxdObject) -> Option<Self> {
|
|
let t = o.tokens();
|
|
// Faces are `(texture, FaceID)` pairs after the `Faces` marker (value-
|
|
// before-key: the FaceID names the emotion, the `.t32` before it is its art).
|
|
let mut faces = Vec::new();
|
|
if let Some(start) = t.iter().position(|s| s == "Faces") {
|
|
for i in (start + 1)..t.len() {
|
|
if t[i].starts_with("Face") && i > 0 {
|
|
faces.push(Face {
|
|
id: t[i].clone(),
|
|
texture: t[i - 1].clone(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Some(Character {
|
|
id: o.get_raw("ID").map(str::to_string),
|
|
name_key: o.get_raw("Name").map(str::to_string),
|
|
faction: o.get_raw("SideID").map(str::to_string),
|
|
unique: o.get_bool("Unique"),
|
|
faces,
|
|
fields: field_map(o),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Load every character from a pak.
|
|
pub fn load_characters(pak: &PakArchive) -> Vec<Character> {
|
|
load_table(pak, schema::CHARACTER, Character::from_idxd)
|
|
}
|
|
|
|
// ── Stage / mission ─────────────────────────────────────────────────────────────
|
|
|
|
/// The raw token immediately before `key` in `tokens` (value-before-key), for
|
|
/// identifier-valued fields the generic resolver skips (filenames, locations).
|
|
fn raw_before<'a>(tokens: &'a [String], key: &str) -> Option<&'a str> {
|
|
let i = tokens.iter().position(|t| t == key)?;
|
|
(i > 0).then(|| tokens[i - 1].as_str())
|
|
}
|
|
|
|
/// A mission / stage (schema [`STAGE`](schema::STAGE)). The manifest that wires a
|
|
/// mission together: its location, phase count, and the per-stage tables that
|
|
/// populate it (unit roster, squadrons, formations, flight routes, AI, objectives).
|
|
/// Resolve the referenced `*_S<NN>.tbl` tables with [`load_records`] for the
|
|
/// spawn/formation detail.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Stage {
|
|
/// Stage tag derived from the unit-table name, e.g. `"S01"`.
|
|
pub id: String,
|
|
/// In-world location (`BackGroundID`), e.g. `Lebendorf`, `Acheron`, `Earth`.
|
|
pub location: Option<String>,
|
|
/// Number of `Phase_N` sections.
|
|
pub phases: u32,
|
|
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>,
|
|
/// Every `resource-kind → table` reference in the manifest.
|
|
pub resources: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl Stage {
|
|
fn from_idxd(o: &IdxdObject) -> Option<Self> {
|
|
let t = o.tokens();
|
|
// Each resource is `table-name → Enumerate<Kind>` (value-before-key).
|
|
let mut resources = BTreeMap::new();
|
|
for i in 1..t.len() {
|
|
let key = t[i].as_str();
|
|
if key.starts_with("Enumerate") || key == "MessageSet" || key == "NamePlate" {
|
|
resources.insert(key.to_string(), t[i - 1].clone());
|
|
}
|
|
}
|
|
let unit_table = raw_before(t, "EnumerateUnit").map(str::to_string);
|
|
let id = unit_table
|
|
.as_deref()
|
|
.and_then(|s| s.strip_prefix("EnumUnit_"))
|
|
.map(|s| s.trim_end_matches(".tbl").to_string())
|
|
.unwrap_or_default();
|
|
Some(Stage {
|
|
id,
|
|
location: o.get_raw("BackGroundID").map(str::to_string),
|
|
phases: t.iter().filter(|s| s.starts_with("Phase_")).count() as u32,
|
|
unit_table,
|
|
character_table: raw_before(t, "EnumerateCharacter").map(str::to_string),
|
|
squadron_table: raw_before(t, "EnumerateSquadron").map(str::to_string),
|
|
formation_table: raw_before(t, "EnumerateFormation").map(str::to_string),
|
|
route_table: raw_before(t, "EnumerateNullFrame").map(str::to_string),
|
|
ai_params_table: raw_before(t, "EnumerateAIParams").map(str::to_string),
|
|
subobjective_table: raw_before(t, "EnumerateSubobjective").map(str::to_string),
|
|
message_table: raw_before(t, "MessageSet").map(str::to_string),
|
|
resources,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Load every stage / mission manifest from a pak.
|
|
pub fn load_stages(pak: &PakArchive) -> Vec<Stage> {
|
|
load_table(pak, schema::STAGE, Stage::from_idxd)
|
|
}
|
|
|
|
// ── Squadron / flight group ─────────────────────────────────────────────────────
|
|
|
|
/// A squadron — a flight group that spawns together: its formation shape, AI
|
|
/// behaviour, side, and member craft (with their pilots). Parsed from the
|
|
/// per-stage `Enumerate_Squadrons` tables (`UnitGroup_S<NN>.tbl`). The player's
|
|
/// Rhino flight, for instance, is a 2-craft TCAF squadron flying `Formation_2_Rhino`.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Squadron {
|
|
/// Squadron tag (`TCN001`, `ADT101`, …), matched positionally to its
|
|
/// definition; `None` if the id list and definitions don't line up.
|
|
pub id: Option<String>,
|
|
pub formation_id: Option<String>,
|
|
pub ai_id: Option<String>,
|
|
/// Faction — `TCAF` (allied) or `ADAN` (enemy).
|
|
pub side: Option<String>,
|
|
pub count: Option<i64>,
|
|
/// Member craft (`UN_*` unit ids).
|
|
pub members: Vec<String>,
|
|
}
|
|
|
|
/// Load every squadron from a pak. Scans all `Enumerate_Squadrons` tables (their
|
|
/// schema hash varies per stage, but the table name is stable).
|
|
pub fn load_squadrons(pak: &PakArchive) -> Vec<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 t = o.tokens();
|
|
if !t.first().is_some_and(|s| s.contains("Enumerate_Squadron")) {
|
|
continue;
|
|
}
|
|
// Definitions are delimited by the `FormationID` key (its value, the
|
|
// formation, sits just before it). The id list precedes the first one.
|
|
let fpos: Vec<usize> = t
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, s)| *s == "FormationID")
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
let Some(&first) = fpos.first() else { continue };
|
|
// The squadron id list: tokens after the header up to the first formation.
|
|
let ids: Vec<&String> = t[1..first.saturating_sub(1)].iter().collect();
|
|
for (k, &fi) in fpos.iter().enumerate() {
|
|
let start = fi.saturating_sub(1);
|
|
let end = fpos.get(k + 1).map(|&n| n.saturating_sub(1)).unwrap_or(t.len());
|
|
let span = &t[start..end];
|
|
let before = |key: &str| -> Option<String> {
|
|
span.iter().position(|s| s == key).and_then(|i| (i > 0).then(|| span[i - 1].clone()))
|
|
};
|
|
let count = before("Count").and_then(|v| v.parse::<i64>().ok());
|
|
// Members are the first `count` `UN_` craft after the header block
|
|
// (which ends at DisableInterval, else Count); anything past them
|
|
// belongs to the next squadron or the stage roster.
|
|
let member_start = span
|
|
.iter()
|
|
.position(|s| s == "DisableInterval")
|
|
.or_else(|| span.iter().position(|s| s == "Count"))
|
|
.map(|i| i + 1)
|
|
.unwrap_or(0);
|
|
let take = count.unwrap_or(0).max(0) as usize;
|
|
out.push(Squadron {
|
|
id: (ids.len() == fpos.len()).then(|| ids[k].clone()),
|
|
formation_id: Some(t[fi - 1].clone()),
|
|
ai_id: before("AIID"),
|
|
side: before("SideID"),
|
|
count,
|
|
members: span[member_start..]
|
|
.iter()
|
|
.filter(|s| s.starts_with("UN_"))
|
|
.take(take)
|
|
.cloned()
|
|
.collect(),
|
|
});
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
// ── Demo message / dialogue line ────────────────────────────────────────────────
|
|
|
|
/// One dialogue line (schema [`MESSAGE`](schema::MESSAGE)): who speaks, with which
|
|
/// portrait and voice clip, and the text-key(s) of its caption pages. Resolve the
|
|
/// page keys against a [`crate::localization::TextIndex`] for the words, and the
|
|
/// voice clip against the movie-voice banks for the audio.
|
|
#[derive(Debug, Clone)]
|
|
pub struct DemoMessage {
|
|
/// Line id, e.g. `MSG_VOICE_A_007`.
|
|
pub id: String,
|
|
/// Speaker (`CharacterRAYMOND`) when the line names one; many system /
|
|
/// continuation lines are unattributed (`None`).
|
|
pub character: Option<String>,
|
|
/// Portrait / emotion (`FaceRAYMOND_07`).
|
|
pub face: Option<String>,
|
|
/// Voice clip token (`VOICE_A_007`).
|
|
pub voice_clip: Option<String>,
|
|
/// Caption page text keys (`MSG_VOICE_A_007_000_00`, …) — resolve for the text.
|
|
pub page_keys: Vec<String>,
|
|
}
|
|
|
|
/// Load every dialogue line from a pak. Each `Message_NNN` sub-record within the
|
|
/// message blobs becomes one [`DemoMessage`]; fields are found by prefix, so the
|
|
/// positional/defaulted layout doesn't matter.
|
|
pub fn load_demo_messages(pak: &PakArchive) -> Vec<DemoMessage> {
|
|
let is_marker = |s: &str| {
|
|
s.strip_prefix("Message_").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
|
|
};
|
|
let mut out = Vec::new();
|
|
for e in pak.entries() {
|
|
let Ok(b) = pak.read(e) else { continue };
|
|
let Ok(o) = IdxdObject::parse(&b) else { continue };
|
|
if o.schema_hash != schema::MESSAGE {
|
|
continue;
|
|
}
|
|
let t = o.tokens();
|
|
let marks: Vec<usize> = t.iter().enumerate().filter(|(_, s)| is_marker(s)).map(|(i, _)| i).collect();
|
|
for (k, &m) in marks.iter().enumerate() {
|
|
let end = marks.get(k + 1).copied().unwrap_or(t.len());
|
|
let slice = &t[m + 1..end];
|
|
let Some(id) = slice.first().cloned() else { continue };
|
|
out.push(DemoMessage {
|
|
character: slice.iter().find(|s| s.starts_with("Character")).cloned(),
|
|
face: slice.iter().find(|s| s.starts_with("Face")).cloned(),
|
|
voice_clip: slice.iter().find(|s| s.starts_with("VOICE_")).cloned(),
|
|
page_keys: slice
|
|
.iter()
|
|
.filter(|s| s.len() > id.len() && s.starts_with(&id) && s[id.len()..].starts_with('_'))
|
|
.cloned()
|
|
.collect(),
|
|
id,
|
|
});
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
// ── Generic record access (any of the ~105 schemas) ─────────────────────────────
|
|
|
|
/// A generically-decoded IDXD record: its table name, schema id, identity, and
|
|
/// every explicitly-set field. Use this to read the ~99 schemas without a bespoke
|
|
/// struct (missions, UI layouts, effects, enums, …).
|
|
#[derive(Debug, Clone)]
|
|
pub struct Record {
|
|
/// The table name — `token[0]` (e.g. `Weapon`, `StageResource`, `Sperkers`).
|
|
/// May carry a stray leading byte from the string-pool boundary on some
|
|
/// records; group by [`schema`](Self::schema), not this, for reliability.
|
|
pub table: String,
|
|
pub schema: u32,
|
|
pub id: Option<String>,
|
|
pub name: Option<String>,
|
|
/// Every explicitly-set field (`key → value`).
|
|
pub fields: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl Record {
|
|
fn from_idxd(o: &IdxdObject) -> Option<Self> {
|
|
Some(Record {
|
|
table: o.tokens().first().cloned().unwrap_or_default(),
|
|
schema: o.schema_hash,
|
|
id: o.get_raw("ID").map(str::to_string),
|
|
name: o.get_raw("Name").map(str::to_string),
|
|
fields: field_map(o),
|
|
})
|
|
}
|
|
pub fn f32(&self, key: &str) -> Option<f32> {
|
|
as_f32(self.fields.get(key))
|
|
}
|
|
pub fn i64(&self, key: &str) -> Option<i64> {
|
|
as_i64(self.fields.get(key))
|
|
}
|
|
}
|
|
|
|
/// Load every record of an arbitrary `schema` from a pak, generically. Pair with
|
|
/// the [`schema`] constants or a hash from the IDXD census.
|
|
pub fn load_records(pak: &PakArchive, schema: u32) -> Vec<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".to_string())), Some(3.0));
|
|
assert_eq!(as_f32(Some(&"-0.5".to_string())), Some(-0.5));
|
|
assert_eq!(as_f32(Some(&"Vessel,Craft".to_string())), None);
|
|
}
|
|
|
|
// Integration tests against a real disc — skipped unless SYLPHEED_DISC is set.
|
|
fn pak() -> Option<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!(ws.len() >= 100, "expected ≥100 weapons, got {}", ws.len());
|
|
let rocket = ws
|
|
.iter()
|
|
.find(|w| w.id.as_deref().is_some_and(|s| s.contains("DeltaSaber_Rocket")))
|
|
.expect("Delta Saber rocket present");
|
|
assert_eq!(rocket.velocity, Some(3000.0));
|
|
assert_eq!(rocket.max_range, Some(6000.0));
|
|
assert_eq!(rocket.loading_count, Some(1000));
|
|
assert_eq!(rocket.target_type.as_deref(), Some("Vessel,Craft,Structure"));
|
|
}
|
|
|
|
#[test]
|
|
fn loads_units_and_vessels() {
|
|
let Some(pak) = pak() else { return };
|
|
let units = load_units(&pak);
|
|
assert!(units.len() >= 80);
|
|
// Player craft: HP 1000, radar 500000.
|
|
let player = units
|
|
.iter()
|
|
.find(|u| u.id.as_deref().is_some_and(|s| s.contains("DeltaSaber_T")))
|
|
.expect("player craft present");
|
|
assert_eq!(player.hp, Some(1000.0));
|
|
assert_eq!(player.radar_range, Some(500000.0));
|
|
|
|
let vessels = load_vessels(&pak);
|
|
let flagship = vessels
|
|
.iter()
|
|
.find(|v| v.id.as_deref().is_some_and(|s| s.contains("SDBattleship")))
|
|
.expect("SD-Battleship present");
|
|
assert_eq!(flagship.hp, Some(100000.0));
|
|
assert_eq!(flagship.turret_count, Some(25));
|
|
}
|
|
|
|
#[test]
|
|
fn loads_player_configs() {
|
|
let Some(pak) = pak() else { return };
|
|
let cfgs = load_player_configs(&pak);
|
|
assert!(!cfgs.is_empty());
|
|
let c = &cfgs[0];
|
|
assert_eq!(c.bullet_limit, Some(512));
|
|
assert_eq!(c.laser_limit, Some(32));
|
|
assert_eq!(c.rank_score_s, Some(10000));
|
|
}
|
|
|
|
#[test]
|
|
fn loads_characters() {
|
|
let Some(pak) = pak() else { return };
|
|
let chars = load_characters(&pak);
|
|
assert!(chars.len() >= 20, "expected many characters, got {}", chars.len());
|
|
let raymond = chars
|
|
.iter()
|
|
.find(|c| c.id.as_deref() == Some("CharacterRAYMOND"))
|
|
.expect("Raymond present");
|
|
assert_eq!(raymond.faction.as_deref(), Some("TCAF"));
|
|
assert!(!raymond.faces.is_empty());
|
|
assert!(raymond.faces.iter().all(|f| f.texture.ends_with(".t32")));
|
|
}
|
|
|
|
#[test]
|
|
fn loads_stages() {
|
|
let Some(pak) = pak() else { return };
|
|
let stages = load_stages(&pak);
|
|
let s01 = stages.iter().find(|s| s.id == "S01").expect("stage S01 present");
|
|
assert_eq!(s01.location.as_deref(), Some("Lebendorf"));
|
|
assert_eq!(s01.phases, 3);
|
|
assert_eq!(s01.unit_table.as_deref(), Some("EnumUnit_S01.tbl"));
|
|
assert_eq!(s01.squadron_table.as_deref(), Some("UnitGroup_S01.tbl"));
|
|
// 16-mission main campaign is present.
|
|
for n in 1..=16 {
|
|
assert!(stages.iter().any(|s| s.id == format!("S{n:02}")), "missing S{n:02}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn loads_squadrons() {
|
|
let Some(pak) = pak() else { return };
|
|
let sq = load_squadrons(&pak);
|
|
assert!(sq.len() >= 20, "expected many squadrons, got {}", sq.len());
|
|
// A TCAF Rhino flight of Delta Sabers, membership matching its count.
|
|
let rhino = sq
|
|
.iter()
|
|
.find(|s| {
|
|
s.side.as_deref() == Some("TCAF")
|
|
&& s.formation_id.as_deref().is_some_and(|f| f.contains("Rhino"))
|
|
&& s.members.iter().any(|m| m.contains("DeltaSaber"))
|
|
})
|
|
.expect("a TCAF Rhino Delta Saber squadron");
|
|
assert_eq!(rhino.members.len() as i64, rhino.count.unwrap_or(-1));
|
|
assert!(rhino.members.iter().all(|m| m.contains("DeltaSaber")));
|
|
}
|
|
|
|
#[test]
|
|
fn loads_demo_messages() {
|
|
let Some(pak) = pak() else { return };
|
|
let msgs = load_demo_messages(&pak);
|
|
assert!(msgs.len() > 200, "expected many dialogue lines, got {}", msgs.len());
|
|
// Most lines are `MSG_*` ids with a speaker; page keys extend the id.
|
|
let msg_ids = msgs.iter().filter(|m| m.id.starts_with("MSG_")).count();
|
|
assert!(msg_ids > msgs.len() * 9 / 10, "{msg_ids}/{}", msgs.len());
|
|
// Attributed dialogue (an explicit speaker) is a minority — most lines are
|
|
// unattributed system/continuation messages — but there are hundreds.
|
|
let attributed = msgs.iter().filter(|m| m.character.is_some()).count();
|
|
assert!(attributed > 500, "attributed lines: {attributed}");
|
|
let m = msgs.iter().find(|m| m.character.is_some() && !m.page_keys.is_empty()).unwrap();
|
|
assert!(m.page_keys.iter().all(|k| k.starts_with(&m.id)));
|
|
}
|
|
|
|
#[test]
|
|
fn generic_records_read_any_schema() {
|
|
let Some(pak) = pak() else { return };
|
|
// Stage-resource table via the generic reader (no bespoke struct).
|
|
let stages = load_records(&pak, 0x3c9a_e32e);
|
|
assert!(!stages.is_empty());
|
|
assert!(stages.iter().all(|r| r.table.ends_with("StageResource")));
|
|
}
|
|
}
|