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:
105
crates/sylpheed-formats/src/unit_layout.rs
Normal file
105
crates/sylpheed-formats/src/unit_layout.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
//! The runtime layout of a unit / vessel definition object.
|
||||
//!
|
||||
//! The game parses an [`crate::idxd`] record into a fixed 880-byte object whose
|
||||
//! field offsets are **not** guessable from the disc data: the record is a
|
||||
//! reflective key/value pool, and the loader assigns each key to a member by
|
||||
//! name. This table is that assignment, read out of the loader itself
|
||||
//! (`sub_82341A20` — every key is built as `addi r4, r30, -N`, so the field name
|
||||
//! for each store is a string in the executable image), and verified against
|
||||
//! objects dumped from a running mission: **406 values agree with the disc
|
||||
//! records, 0 disagree**, over 11 objects covering both schemas.
|
||||
//!
|
||||
//! Why a reimplementation wants it:
|
||||
//!
|
||||
//! - it names the field behind every word of a live definition object, so a
|
||||
//! memory snapshot can be read directly;
|
||||
//! - it says which fields a record leaves **defaulted**, and what the loader
|
||||
//! leaves there — the float accessor returns `0.0` on a pool miss;
|
||||
//! - it carries two conventions that are invisible on disc: **angles are degrees
|
||||
//! in the data and radians in the object**, and **`Size_Y` takes `Size_X`**
|
||||
//! when omitted.
|
||||
//!
|
||||
//! See `docs/re/live-unit-definitions.md` for the derivation and the measured
|
||||
//! default values.
|
||||
|
||||
/// How a field is stored in the definition object.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// IEEE-754 single, big-endian.
|
||||
F32,
|
||||
/// Pointer to a string.
|
||||
Str,
|
||||
/// 32-bit word (bool / enum / count / id).
|
||||
Word,
|
||||
}
|
||||
|
||||
/// One field of the definition object.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Field {
|
||||
/// Byte offset from the start of the object.
|
||||
pub offset: usize,
|
||||
/// Field name, exactly as the disc record spells it.
|
||||
pub name: &'static str,
|
||||
/// Storage kind.
|
||||
pub kind: Kind,
|
||||
}
|
||||
|
||||
const TABLE: &str = include_str!("../data/unit_definition_layout.txt");
|
||||
|
||||
/// Every mapped field, in offset order.
|
||||
pub fn fields() -> Vec<Field> {
|
||||
TABLE
|
||||
.lines()
|
||||
.filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty())
|
||||
.filter_map(|l| {
|
||||
let mut it = l.split_whitespace();
|
||||
let offset = it.next()?.parse().ok()?;
|
||||
let kind = match it.next()? {
|
||||
"f32" => Kind::F32,
|
||||
"str" => Kind::Str,
|
||||
_ => Kind::Word,
|
||||
};
|
||||
// `name` is a &'static str because TABLE is 'static.
|
||||
let name = it.next()?;
|
||||
let name: &'static str = TABLE.get(
|
||||
TABLE.find(name).map(|s| s..s + name.len())?,
|
||||
)?;
|
||||
Some(Field { offset, name, kind })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The field at `offset`, if one is mapped there.
|
||||
pub fn field_at(offset: usize) -> Option<Field> {
|
||||
fields().into_iter().find(|f| f.offset == offset)
|
||||
}
|
||||
|
||||
/// The offset of `name`, if it is mapped.
|
||||
pub fn offset_of(name: &str) -> Option<usize> {
|
||||
fields().into_iter().find(|f| f.name == name).map(|f| f.offset)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_parses_and_is_ordered() {
|
||||
let f = fields();
|
||||
assert!(f.len() > 150, "expected the full map, got {}", f.len());
|
||||
assert!(f.windows(2).all(|w| w[0].offset < w[1].offset), "offsets must be strictly increasing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_fields_sit_where_the_loader_puts_them() {
|
||||
// Spot-checks from the verified map; these four also anchor the
|
||||
// identification of a live object (see docs/re/live-unit-definitions.md).
|
||||
assert_eq!(offset_of("Size_X"), Some(48));
|
||||
assert_eq!(offset_of("Size_Y"), Some(52));
|
||||
assert_eq!(offset_of("Size_Z"), Some(56));
|
||||
assert_eq!(offset_of("HP"), Some(84));
|
||||
assert_eq!(offset_of("HQRatio"), Some(88));
|
||||
assert_eq!(field_at(96).map(|f| f.name), Some("ThrusterRatio"));
|
||||
assert_eq!(field_at(48).map(|f| f.kind), Some(Kind::F32));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user