A disc-gated test that fails if the loaders regress to one flat answer per object. It checks the three structural claims that came with the migration request, and one of them is overstated: * ✅ MainMissionBonus IS per difficulty — 18 of the 24 missions pay three different values, in a strict 1:2:4 easy:normal:hard ratio (all 24); the other 6 pay 0 at every difficulty. The flat reader returned the Easy value. * ✅ RankScore_S/A/B/C/D repeat identically across the three Score_* records in 24 of 24 missions, so the single flat answer happened to be right. The difficulty scaling lives in the earnings (CraftScore_Adjustment, KillBonus_Maximum, …), not in the thresholds. * 🟡 SpaceSize is *stored* per phase (three Phase_N records), but only **1 of the 24** missions varies it — the one that reads 250000/100000/100000. The other 23 repeat a single value (500000 ×1, 100000 ×18, 50000 ×4). "Phase_1 = 250000, Phase_2/3 = 100000" is one mission's numbers, not a rule. Also pinned: the Delta Saber's 63 Turret_NNN mounts at HP 100 each against a TurretCount of 4; the SD-Battleship's per-component HP (bridge 10000, thruster 20000, shield generator 5000 at PowerRatio 0.25, hatch 100 launching Squadron_Test2, anti-ship gun 1500) against a hull of 100000, and its 37 records that define an HP; 418 hardpoints across the 23 capital ships, none missing HP; 131 weapons whose shell id always differs from the launcher id, 40 with a ShellWake, and Weapon_NULL as the only one without a Shell.Power; and fcs_range/shield_ratio/cruising_velocity/maximum_velocity/acceleration/ deceleration now set on 89 of 89 units, all of which the pool reader could miss. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
310 lines
13 KiB
Rust
310 lines
13 KiB
Rust
//! 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<PakArchive> {
|
|
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<String> = 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<Option<f32>> = c.phases.iter().map(|p| p.space_size).collect();
|
|
let shapes: BTreeSet<String> = 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<f32>| 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<i64> = Some(1000);
|
|
const PIN_C0_NORMAL: Option<i64> = Some(2000);
|
|
const PIN_C0_HARD: Option<i64> = Some(4000);
|
|
const PIN_EASY_DMG: Option<f32> = Some(1.5);
|
|
const PIN_HARD_SHIELD: Option<f32> = 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<f32> = Some(250000.0);
|
|
const PIN_C0_SPACE2: Option<f32> = Some(100000.0);
|
|
const PIN_C0_SPACE3: Option<f32> = 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;
|