diff --git a/crates/sylpheed-formats/tests/game_data_disc.rs b/crates/sylpheed-formats/tests/game_data_disc.rs new file mode 100644 index 0000000..71ad02a --- /dev/null +++ b/crates/sylpheed-formats/tests/game_data_disc.rs @@ -0,0 +1,309 @@ +//! Real-disc tests for the record-table game-data loaders. Skipped without +//! `SYLPHEED_DISC`. +//! +//! These pin the values that only became readable once the loaders moved off the +//! string-pool reader (`docs/re/idxd-legacy-reader-audit.md`): the per-hardpoint +//! stats, the per-difficulty scoring and the per-phase play space. A regression +//! to the flat reader collapses each of them to one answer and fails here. + +use std::collections::BTreeSet; + +use sylpheed_formats::game_data::{self as gd, Difficulty, HardpointKind}; +use sylpheed_formats::PakArchive; + +fn main_pak() -> Option { + let disc = std::env::var("SYLPHEED_DISC").ok()?; + PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).ok() +} + +fn skip() { + eprintln!("SKIP: set SYLPHEED_DISC"); +} + +/// The player's craft carries 63 `Turret_NNN` records — one per equippable +/// weapon — each with its own `HP`, while `StructureCount.TurretCount` says 4. +/// The flat reader had one `HP` slot for the whole object. +#[test] +fn craft_hardpoints_are_per_record() { + let Some(pak) = main_pak() else { return skip() }; + let units = gd::load_units(&pak); + let saber = units + .iter() + .find(|u| u.id.as_deref() == Some("UN_f001_TCAF_DeltaSaber_T")) + .expect("Delta Saber present"); + assert_eq!(saber.hp, Some(1000.0), "hull HP"); + assert_eq!(saber.turret_count, Some(4), "StructureCount.TurretCount"); + let turrets: Vec<_> = saber + .hardpoints + .iter() + .filter(|h| h.kind == HardpointKind::Turret) + .collect(); + assert_eq!(turrets.len(), 63, "Turret_NNN records"); + assert!(turrets.iter().all(|t| t.hp == Some(100.0)), "every mount has HP 100"); + // Each mount names its own weapon model, `rou_f001_wep_NN`. + let mount = turrets.iter().find(|t| t.index == 33).expect("Turret_033"); + assert_eq!(mount.model.as_deref(), Some("rou_f001_wep_33")); + assert_eq!(mount.id.as_deref(), Some("Turret_033")); +} + +/// A capital ship's components each carry their own HP — five distinct values on +/// the SD-Battleship, where the flat reader returned only the hull's 100000. +#[test] +fn vessel_hardpoints_are_per_record() { + let Some(pak) = main_pak() else { return skip() }; + let vessels = gd::load_vessels(&pak); + let bs = vessels + .iter() + .find(|v| v.id.as_deref() == Some("UN_e101_ADAN_SDBattleship")) + .expect("SD-Battleship present"); + assert_eq!(bs.hp, Some(100000.0)); + let hp_of = |kind: HardpointKind, idx: u32| { + bs.hardpoints + .iter() + .find(|h| h.kind == kind && h.index == idx) + .and_then(|h| h.hp) + }; + assert_eq!(hp_of(HardpointKind::Bridge, 0), Some(10000.0)); + assert_eq!(hp_of(HardpointKind::Thruster, 0), Some(20000.0)); + assert_eq!(hp_of(HardpointKind::ShieldGenerator, 0), Some(5000.0)); + assert_eq!(hp_of(HardpointKind::Hatch, 0), Some(100.0)); + // Distinct HP values across the ship's components. + let distinct: BTreeSet = bs + .hardpoints + .iter() + .filter_map(|h| h.hp.map(|v| format!("{v}"))) + .collect(); + assert!(distinct.len() >= 4, "distinct component HP values: {distinct:?}"); + // A main gun names the weapon it fires and the shield generator its share. + let asgun = bs + .hardpoints + .iter() + .find(|h| h.weapon_id.as_deref() == Some("Weapon_ADAN_Ship_ASGun")) + .expect("an anti-ship gun"); + assert_eq!(asgun.hp, Some(1500.0)); + assert_eq!( + bs.hardpoints + .iter() + .find(|h| h.kind == HardpointKind::ShieldGenerator) + .and_then(|h| h.power_ratio), + Some(0.25) + ); + // The launch bay names the squadron it scrambles. + assert_eq!( + bs.hardpoints + .iter() + .find(|h| h.kind == HardpointKind::Hatch) + .and_then(|h| h.squadron_id.clone()), + Some("Squadron_Test2".to_string()) + ); +} + +/// `MainMissionBonus` lives in `Score_Easy` / `Score_Normal` / `Score_Hard`, one +/// value each. The flat reader returned the Easy one for every mission. +#[test] +fn main_mission_bonus_is_per_difficulty() { + let Some(pak) = main_pak() else { return skip() }; + let cfgs = gd::load_player_configs(&pak); + assert_eq!(cfgs.len(), 24); + let mut distinct3 = 0; + let mut doubling = 0; + let mut all_equal = 0; + for c in &cfgs { + let (e, n, h) = ( + c.score.easy.main_mission_bonus, + c.score.normal.main_mission_bonus, + c.score.hard.main_mission_bonus, + ); + let set: BTreeSet<_> = [e, n, h].into_iter().flatten().collect(); + match set.len() { + 3 => distinct3 += 1, + 1 => all_equal += 1, + _ => {} + } + if let (Some(e), Some(n), Some(h)) = (e, n, h) { + if n == 2 * e && h == 4 * e { + doubling += 1; + } + } + } + eprintln!("MainMissionBonus: 3-distinct {distinct3}/24, all-equal {all_equal}, doubling {doubling}"); + assert_eq!(distinct3, PIN_BONUS_DISTINCT3); + assert_eq!(doubling, PIN_BONUS_DOUBLING); + // The first config's own numbers. + let c = &cfgs[0]; + assert_eq!(c.score.easy.main_mission_bonus, PIN_C0_EASY); + assert_eq!(c.score.normal.main_mission_bonus, PIN_C0_NORMAL); + assert_eq!(c.score.hard.main_mission_bonus, PIN_C0_HARD); +} + +/// The rank thresholds repeat identically across the three `Score_*` records — +/// so the one flat answer happened to be right, but only by coincidence. +#[test] +fn rank_scores_repeat_across_difficulties() { + let Some(pak) = main_pak() else { return skip() }; + let cfgs = gd::load_player_configs(&pak); + let mut identical = 0; + for c in &cfgs { + let row = |s: &gd::ScoreRules| { + (s.rank_score_s, s.rank_score_a, s.rank_score_b, s.rank_score_c, s.rank_score_d) + }; + if row(&c.score.easy) == row(&c.score.normal) && row(&c.score.normal) == row(&c.score.hard) { + identical += 1; + } + } + eprintln!("rank scores identical across difficulties: {identical}/{}", cfgs.len()); + assert_eq!(identical, cfgs.len()); + assert_eq!(cfgs[0].score.normal.rank_score_s, Some(10000)); + assert_eq!(cfgs[0].score.normal.rank_score_d, Some(1000)); + // The difficulty scaling that *is* per record. + assert_eq!(cfgs[0].difficulty_f32(Difficulty::Easy, "DamageAdjustment"), PIN_EASY_DMG); + assert_eq!(cfgs[0].difficulty_f32(Difficulty::Hard, "ShieldDamageAdjustment"), PIN_HARD_SHIELD); +} + +/// `SpaceSize` lives in `Phase_1` / `Phase_2` / `Phase_3`, one value each. +#[test] +fn space_size_is_per_phase() { + let Some(pak) = main_pak() else { return skip() }; + let cfgs = gd::load_player_configs(&pak); + let mut varying = 0; + let mut p1_250k = 0; + for c in &cfgs { + assert_eq!(c.phases.len(), 3, "every mission has three Phase_N records"); + let sizes: Vec> = c.phases.iter().map(|p| p.space_size).collect(); + let shapes: BTreeSet = sizes.iter().map(|v| format!("{v:?}")).collect(); + if shapes.len() > 1 { + varying += 1; + } + if sizes[0] == Some(250000.0) { + p1_250k += 1; + } + } + eprintln!("SpaceSize: varying across phases {varying}/24, phase 1 = 250000 in {p1_250k}"); + assert_eq!(varying, PIN_SPACE_VARYING); + assert_eq!(p1_250k, PIN_SPACE_P1_250K); + let c = &cfgs[0]; + assert_eq!(c.space_size(1), PIN_C0_SPACE1); + assert_eq!(c.space_size(2), PIN_C0_SPACE2); + assert_eq!(c.space_size(3), PIN_C0_SPACE3); +} + +/// Fields the string-pool reader returned `None` for now have values, disc-wide. +#[test] +fn previously_unreadable_stats_are_populated() { + let Some(pak) = main_pak() else { return skip() }; + let units = gd::load_units(&pak); + let vessels = gd::load_vessels(&pak); + let n = |f: fn(&gd::CraftUnit) -> Option| units.iter().filter(|u| f(u).is_some()).count(); + eprintln!( + "units={} fcs={} shield_ratio={} cruise={} maxvel={} accel={} decel={} shield_max={}", + units.len(), + n(|u| u.fcs_range), + n(|u| u.shield_ratio), + n(|u| u.cruising_velocity), + n(|u| u.maximum_velocity), + n(|u| u.acceleration), + n(|u| u.deceleration), + n(|u| u.shield_max), + ); + for (label, count) in [ + ("fcs_range", n(|u| u.fcs_range)), + ("shield_ratio", n(|u| u.shield_ratio)), + ("cruising_velocity", n(|u| u.cruising_velocity)), + ("maximum_velocity", n(|u| u.maximum_velocity)), + ("acceleration", n(|u| u.acceleration)), + ("deceleration", n(|u| u.deceleration)), + ] { + assert_eq!(count, units.len(), "{label} set on every unit"); + } + assert!(vessels.iter().all(|v| v.fcs_range.is_some() && v.shield_ratio.is_some())); + // Hardpoint HP is readable for every vessel component. + let hp_missing = vessels + .iter() + .flat_map(|v| &v.hardpoints) + .filter(|h| h.hp.is_none()) + .count(); + assert_eq!(hp_missing, 0); + let total_hardpoints: usize = vessels.iter().map(|v| v.hardpoints.len()).sum(); + eprintln!("vessel hardpoints: {total_hardpoints}"); + assert_eq!(total_hardpoints, PIN_VESSEL_HARDPOINTS); +} + +/// The launcher and its projectile are two records with two ids; the tracer has a +/// third `Interval`. +#[test] +fn weapon_launcher_and_shell_stay_separate() { + let Some(pak) = main_pak() else { return skip() }; + let ws = gd::load_weapons(&pak); + assert_eq!(ws.len(), 131); + let differing = ws + .iter() + .filter(|w| w.id.is_some() && w.shell_id.is_some() && w.id != w.shell_id) + .count(); + eprintln!("weapons whose shell id differs from the launcher id: {differing}/{}", ws.len()); + assert_eq!(differing, PIN_WEAPON_SHELL_IDS); + // A player missile the corpus quotes through the flat reader — the launcher + // fields agree, and `MaximumRange` (a `Shell` field) reads the same. + let m26 = ws + .iter() + .find(|w| w.id.as_deref() == Some("Weapon_DSaber_P_wep_26_Missile")) + .expect("Weapon_DSaber_P_wep_26_Missile is in GP_MAIN_GAME_E.pak, not the hangar pak"); + assert_eq!(m26.target_type.as_deref(), Some("Vessel,Craft")); + assert_eq!(m26.loading_count, Some(144)); + assert_eq!(m26.interval, Some(3.0)); + assert_eq!(m26.mass, Some(0.77)); + assert_eq!(m26.trigger_shot_count, Some(12)); + assert_eq!(m26.max_range, Some(10000.0)); + + let with_wake = ws.iter().filter(|w| w.records.record("ShellWake").is_some()).count(); + eprintln!("weapons with a ShellWake record: {with_wake}"); + assert_eq!(with_wake, PIN_WEAPON_WAKE); + // `Power` is a `Shell` field: every weapon but one has it. + let no_power: Vec<&str> = + ws.iter().filter(|w| w.power.is_none()).filter_map(|w| w.id.as_deref()).collect(); + assert_eq!(no_power, ["Weapon_NULL"], "only the placeholder weapon has no Shell.Power"); +} + +/// `RecordSet::everywhere` is the API that answers "which record did that come +/// from" — the question the flat map could not express. +#[test] +fn everywhere_enumerates_the_records_that_define_a_field() { + let Some(pak) = main_pak() else { return skip() }; + let vessels = gd::load_vessels(&pak); + let bs = vessels + .iter() + .find(|v| v.id.as_deref() == Some("UN_e101_ADAN_SDBattleship")) + .unwrap(); + let hps = bs.records.everywhere("HP"); + eprintln!("SDBattleship records defining HP: {}", hps.len()); + assert_eq!(hps.len(), PIN_BS_HP_RECORDS); + assert!(hps.iter().any(|(r, v)| *r == "Generic" && *v == "100000.0")); + assert!(hps.iter().any(|(r, _)| r.starts_with("Turret_"))); +} + +// Pinned measurements over `GP_MAIN_GAME_E.pak`. + +/// 18 of the 24 missions pay a different main-objective bonus per difficulty; +/// the other 6 pay 0 at every difficulty. +const PIN_BONUS_DISTINCT3: usize = 18; +/// …and in all 24 the ratio is easy : normal : hard = 1 : 2 : 4. +const PIN_BONUS_DOUBLING: usize = 24; +const PIN_C0_EASY: Option = Some(1000); +const PIN_C0_NORMAL: Option = Some(2000); +const PIN_C0_HARD: Option = Some(4000); +const PIN_EASY_DMG: Option = Some(1.5); +const PIN_HARD_SHIELD: Option = Some(0.5); +/// `SpaceSize` is stored per phase, but only **one** of the 24 missions actually +/// varies it across its phases (250000 → 100000 → 100000). The other 23 repeat +/// one value: 500000 once, 100000 eighteen times, 50000 four times. +const PIN_SPACE_VARYING: usize = 1; +const PIN_SPACE_P1_250K: usize = 1; +const PIN_C0_SPACE1: Option = Some(250000.0); +const PIN_C0_SPACE2: Option = Some(100000.0); +const PIN_C0_SPACE3: Option = Some(100000.0); +/// Destructible components across the 23 capital ships, each with its own HP. +const PIN_VESSEL_HARDPOINTS: usize = 418; +/// Every weapon's projectile carries an id of its own (`Shell_…` vs `Weapon_…`). +const PIN_WEAPON_SHELL_IDS: usize = 131; +const PIN_WEAPON_WAKE: usize = 40; +/// Records of the SD-Battleship that define an `HP`: the hull plus 36 components. +const PIN_BS_HP_RECORDS: usize = 37;