feat(formats): land the verified unit-definition layout as data + API + test
The offset->field map read out of sub_82341A20 is now usable rather than just documented: data/unit_definition_layout.txt carries the 159 fields with their provenance and the two conventions (angles are degrees on disc and radians in the object; a defaulted field keeps the accessor's 0.0 miss value), and sylpheed_formats::unit_layout exposes fields()/field_at()/offset_of() so a memory snapshot can be read by name. tests/unit_layout_disc.rs replays the verification against the checked-in live dump -- every mapped float of all 11 identified objects must equal its disc value, angles compared in radians -- asserting 0 disagreements and >=400 agreements. It needs no emulator. Full suite green: 11 binaries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
96
crates/sylpheed-formats/tests/unit_layout_disc.rs
Normal file
96
crates/sylpheed-formats/tests/unit_layout_disc.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
//! The definition-object layout must keep agreeing with the disc records.
|
||||
//!
|
||||
//! `unit_layout` was read out of the game's loader and checked against objects
|
||||
//! dumped from a running Stage 02. This pins that check: every mapped float
|
||||
//! field of every identified object must equal the value its disc record sets.
|
||||
//! It needs no emulator — the dump is checked in under `docs/re/captures/`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use sylpheed_formats::idxd::IdxdObject;
|
||||
use sylpheed_formats::pak::PakArchive;
|
||||
use sylpheed_formats::unit_layout::{fields, Kind};
|
||||
|
||||
/// Live objects identified in the dump, with the disc record each one is.
|
||||
/// `bf001` is here because full-record agreement is what identified it: the
|
||||
/// four-value signature also fitted `UN_be005_ADAN_SpaceFortress`, which
|
||||
/// disagrees on `Color_R`/`Color_G`.
|
||||
const IDENTIFIED: &[(&str, &str)] = &[
|
||||
("UN_f106_TCAF_Destroyer", "0xbd3ee300"),
|
||||
("UN_f105_TCAF_Cruiser", "0xbd40e200"),
|
||||
("UN_e105_ADAN_Cruiser", "0xbd3fd800"),
|
||||
("UN_e106_ADAN_Destroyer", "0xbd3e6f80"),
|
||||
("UN_f101_TCAF_Acropolis", "0xbd3e1f00"),
|
||||
("UN_bf001_TCAF_SchlosBase", "0xbd3b6a00"),
|
||||
("UN_e007_ADAN_Turret", "0xbd3dea80"),
|
||||
("UN_e010_ADAN_Attacker_S", "0xbd3faa80"),
|
||||
("UN_f003_TCAF_ArrowHead", "0xbd3dbd00"),
|
||||
("UN_f001_TCAF_DeltaSaber_T", "0xbd3dfc00"),
|
||||
("UN_f001_TCAF_DeltaSaber_T_Player", "0xbd3da100"),
|
||||
];
|
||||
|
||||
const DUMP: &str = include_str!("../../../docs/re/captures/stage02-live-unit-definitions-deep.txt");
|
||||
|
||||
fn disc_root() -> Option<String> {
|
||||
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
|
||||
if std::path::Path::new(&p).join("dat").is_dir() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let d = "/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)";
|
||||
std::path::Path::new(d).join("dat").is_dir().then(|| d.to_string())
|
||||
}
|
||||
|
||||
/// `va -> offset -> value`, from the dump's `addr +off hex u32 f32` columns.
|
||||
fn live() -> BTreeMap<String, BTreeMap<usize, f32>> {
|
||||
let mut out: BTreeMap<String, BTreeMap<usize, f32>> = BTreeMap::new();
|
||||
let mut cur = String::new();
|
||||
for line in DUMP.lines() {
|
||||
if let Some(rest) = line.strip_prefix("=== ") {
|
||||
cur = rest.trim().to_string();
|
||||
continue;
|
||||
}
|
||||
let f: Vec<&str> = line.split_whitespace().collect();
|
||||
if f.len() >= 5 && f[1].starts_with('+') {
|
||||
if let (Ok(off), Ok(val)) = (usize::from_str_radix(&f[1][1..], 16), f[4].parse::<f32>())
|
||||
{
|
||||
out.entry(cur.clone()).or_default().insert(off, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_fields_match_the_disc_records() {
|
||||
let Some(disc) = disc_root() else {
|
||||
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
|
||||
return;
|
||||
};
|
||||
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).expect("main pak");
|
||||
let live = live();
|
||||
let floats: Vec<_> = fields().into_iter().filter(|f| f.kind == Kind::F32).collect();
|
||||
|
||||
let (mut agree, mut bad) = (0usize, Vec::new());
|
||||
for entry in pak.entries() {
|
||||
let Ok(bytes) = pak.read(entry) else { continue };
|
||||
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
|
||||
let Some(id) = obj.get_raw("ID") else { continue };
|
||||
let Some((_, va)) = IDENTIFIED.iter().find(|(i, _)| *i == id) else { continue };
|
||||
let Some(words) = live.get(*va) else { continue };
|
||||
for f in &floats {
|
||||
let (Some(want), Some(got)) = (obj.get_f32(f.name), words.get(&f.offset)) else {
|
||||
continue;
|
||||
};
|
||||
// Angle fields are degrees on disc and radians in the object.
|
||||
let rad = want * std::f32::consts::PI / 180.0;
|
||||
if (want - got).abs() <= want.abs() * 1e-4 || (rad - got).abs() <= rad.abs() * 1e-4 {
|
||||
agree += 1;
|
||||
} else {
|
||||
bad.push(format!("{id}.{} disc {want} vs live +{} = {got}", f.name, f.offset));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(bad.is_empty(), "{} fields disagree: {bad:?}", bad.len());
|
||||
assert!(agree >= 400, "expected ≥400 agreeing fields, got {agree}");
|
||||
}
|
||||
Reference in New Issue
Block a user