//! 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;