Two independent lines landed a `.rat` reading and neither was the whole
picture, so this merges them into one module and fixes what the merge exposed.
ui_layout — the screen is the BUNDLE, not the set of .rat records
------------------------------------------------------------------
`feat/ui-layout-preview` parsed `.rat` records; the autopilot stack documented
the RATC header and probed it in `examples/screen_layout.rs` but never landed a
library module. The `.rat`-only reading structurally cannot see an element that
has no record -- the `eff*` frame corners, the `deli*` dividers, `msg` -- which
is exactly what the committed real-vs-rebuilt capture shows missing. Rebuilt
around the header:
* element declaration table at 0x20 (60-byte entries: name, parent index at
+32, kind flags, pivot) = the back-to-front draw list;
* the placement region after it = per-element keyframe groups.
Verified against the disc, each against a fact the docs state independently:
`pgpeff02a` -> parent 3 = `pgpeff02`; `pgp_ttrl_btn10` rests at (546,288); the
pause buttons sit at 268/337/407/478, the documented 70 px pitch; the Arsenal
carries X = -516. The tutorial PAUSE menu now composites 11/11 elements and
matches the real screen more closely than the earlier rebuild did.
Three defects found while validating, none of which any test would have caught:
* the keyframe block is 40 bytes with X/Y/time at +28/+32/+36 and an
alpha-ramping ARGB at +0 -- the fade, previously unread;
* a group's data stops 4 bytes short of its last block's time slot, so that
word is the NEXT group's element index. Reading it produced times like
1869640736 and silently corrupted the max-dwell pick. Last-frame time is
now `None`;
* the `.rat` sprite-name field is not 16 bytes. Capping it there truncated
`pgp_ttrl_title.t32` to `pgp_ttrl_title.t`, which resolved against nothing
and dropped 4 of 11 tutorial elements from the composite.
Max-dwell also needed a tie-break: on equal gaps take the LATER frame, or
`pgpmsg` reports the y=645 fly-through instead of the y=605 it settles at.
savegame -- a Rust port of tools/re-capture/savegame.py
------------------------------------------------------
GDHA container, zlib payload, chunk stream (GDAA / phase / GHAD 122 B / 16x20 B
SHAB / trailer). Every GHAD word carries its own confidence rather than the
block being presented as solved: 6 named, 2 recorded as REFUTED (+36, +56 were
tested as difficulty and as stage and are neither), 7 still unknown.
Tested against the three real saves committed under docs/re/captures -- no disc
and no emulator needed. The load-bearing assertion is the byte-identical
round-trip; the develop differential is asserted as a property (spending 4000 P
moves +24 and not its twin +28, steps the clear ratio, and moves exactly two
blob entries), and the header summary is checked to agree with the payload it
mirrors -- the trap that makes the Details panel a bad oracle.
CLI: `screen list|info|render` and `save info`, so both are checkable headlessly
in the same spirit as `mesh render`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
605 lines
24 KiB
Rust
605 lines
24 KiB
Rust
//! `savedata` — the retail save file.
|
|
//!
|
|
//! The whole save is **545 bytes**. It lives in the console's content tree as a
|
|
//! single file and there is no second one, so everything the game remembers
|
|
//! between sessions is in here:
|
|
//!
|
|
//! ```text
|
|
//! <content>/<XUID>/535107D4/00000001/game01/savedata
|
|
//! ```
|
|
//!
|
|
//! # Container
|
|
//!
|
|
//! ```text
|
|
//! 'GDHA' <146-byte header> <zlib stream, 78 DA>
|
|
//! ```
|
|
//!
|
|
//! Much of the header is **uninitialised memory** — words like `0x828F3DA8` are
|
|
//! guest virtual addresses that leaked out of the struct's padding — so it is not
|
|
//! byte-reproducible and must not be read as meaningful. Three header fields
|
|
//! *are* derived from the payload ([`Header::derived`]), and a handful more are a
|
|
//! **summary copy** of payload fields ([`Header::summary`]) — which matters more
|
|
//! than it sounds: the in-game Details panel reads the *summary*, not the
|
|
//! payload, so editing a payload field alone leaves a stale panel beside it. A
|
|
//! probe that judges "did that field change the display?" without patching the
|
|
//! mirror cannot tell *"wrong field"* from *"the panel never reads the payload"*.
|
|
//!
|
|
//! # Payload
|
|
//!
|
|
//! A chunk stream, read off the title's own serializer at `0x822C00E8` (its
|
|
//! writer primitive is `0x821885A8(stream, buf, len)`) rather than guessed, and
|
|
//! verified by a byte-identical round-trip:
|
|
//!
|
|
//! ```text
|
|
//! 'GDAA' payload magic
|
|
//! u32 len, char[len] current game phase, e.g. "GP_BUNK"
|
|
//! 'GHAD' + 122 bytes the progress block
|
|
//! u32 16, 16 x ('SHAB' + 5 x u32) the per-stage record table
|
|
//! u32 4, "BUNK", 'NETA', u32 trailer
|
|
//! ```
|
|
//!
|
|
//! The in-memory struct is written field-by-field with no packing changes, so a
|
|
//! payload offset **is** the offset in the live save object: GHAD at `save+8`,
|
|
//! the record table at `save+136`, and the serializer's next access after the
|
|
//! table is `lwz r11,456(save)` — exactly `136 + 16*20`.
|
|
//!
|
|
//! # Confidence
|
|
//!
|
|
//! The container and the chunk layout are ✅ `CONFIRMED`. Individual GHAD fields
|
|
//! are **not** uniformly known — eleven are still ❔ — so every field carries its
|
|
//! own [`Confidence`] and a viewer is expected to show it rather than present the
|
|
//! lot as solved. See `docs/re/structures/savegame-format.md`.
|
|
|
|
use std::io::Read;
|
|
|
|
/// How well a field is understood. Mirrors the `docs/re` convention.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Confidence {
|
|
/// Named and verified against the game.
|
|
Confirmed,
|
|
/// A well-supported reading, not proven.
|
|
Probable,
|
|
/// Unidentified.
|
|
Unknown,
|
|
/// A reading that was tested and **refuted** — recorded so it is not retried.
|
|
Refuted,
|
|
}
|
|
|
|
/// How to render a GHAD word.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum FieldKind {
|
|
/// Plain 32-bit count / enum / id.
|
|
U32,
|
|
/// 64-bit word.
|
|
U64,
|
|
/// Milliseconds, displayed as `m:ss.mmm`.
|
|
Millis,
|
|
/// Percent.
|
|
Percent,
|
|
/// Raw bytes.
|
|
Raw,
|
|
/// The per-item Arsenal development blob.
|
|
DevelopBlob,
|
|
}
|
|
|
|
/// One documented field of the GHAD progress block.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct FieldSpec {
|
|
/// Offset within the 122-byte GHAD block.
|
|
pub offset: usize,
|
|
/// Byte width.
|
|
pub size: usize,
|
|
pub kind: FieldKind,
|
|
pub confidence: Confidence,
|
|
/// Short name, or `""` when the field is unidentified.
|
|
pub name: &'static str,
|
|
/// What is known — including what was ruled out.
|
|
pub note: &'static str,
|
|
}
|
|
|
|
/// The GHAD block's layout, read off `0x822BF678`: ten u32, one u64
|
|
/// (`ld r11,40(r30)`), four u32, a raw 4-byte field and a raw 54-byte blob.
|
|
/// 10·4 + 8 + 4·4 + 4 + 54 = **122**, exactly what the file carries — the layout
|
|
/// is closed, with nothing unaccounted for.
|
|
pub const GHAD_LAYOUT: &[FieldSpec] = &[
|
|
FieldSpec { offset: 0, size: 4, kind: FieldKind::U32, confidence: Confidence::Unknown, name: "", note: "" },
|
|
FieldSpec { offset: 4, size: 4, kind: FieldKind::Millis, confidence: Confidence::Confirmed, name: "FlightTime", note: "Milliseconds. 324773 ms against the panel's `Flight Time 000:05:24`." },
|
|
FieldSpec { offset: 8, size: 4, kind: FieldKind::Percent, confidence: Confidence::Confirmed, name: "ClearRatio", note: "Percent. Not a stage counter: developing one Arsenal weapon stepped it 5 → 6, so it counts collection too." },
|
|
FieldSpec { offset: 12, size: 4, kind: FieldKind::U32, confidence: Confidence::Probable, name: "TimesCleared", note: "Mirrored to header +0x28; the panel prints `Times Cleared`." },
|
|
FieldSpec { offset: 16, size: 4, kind: FieldKind::U32, confidence: Confidence::Unknown, name: "", note: "" },
|
|
FieldSpec { offset: 20, size: 4, kind: FieldKind::U32, confidence: Confidence::Unknown, name: "", note: "" },
|
|
FieldSpec { offset: 24, size: 4, kind: FieldKind::U32, confidence: Confidence::Confirmed, name: "Points", note: "The spendable balance. Separated from +28 by a develop differential: spending 4000 P moved only this field (4101 → 101)." },
|
|
FieldSpec { offset: 28, size: 4, kind: FieldKind::U32, confidence: Confidence::Probable, name: "PointsTotal?", note: "Not the displayed Points — it did NOT move when 4000 P was spent. A lifetime/earned total is the obvious read; unproven until a save is taken after earning." },
|
|
FieldSpec { offset: 32, size: 4, kind: FieldKind::U32, confidence: Confidence::Unknown, name: "", note: "" },
|
|
FieldSpec { offset: 36, size: 4, kind: FieldKind::U32, confidence: Confidence::Refuted, name: "", note: "Tested as difficulty and as stage; probe saves refuted both." },
|
|
FieldSpec { offset: 40, size: 8, kind: FieldKind::U64, confidence: Confidence::Unknown, name: "", note: "" },
|
|
FieldSpec { offset: 48, size: 4, kind: FieldKind::U32, confidence: Confidence::Probable, name: "GameStatus", note: "Mirrored to header +0x18; 0 renders `At Standby`, matching the screen's STATE_STAND_BY / STATE_STAGE_CLEAR / STATE_GAME_CLEAR list." },
|
|
FieldSpec { offset: 52, size: 4, kind: FieldKind::U32, confidence: Confidence::Confirmed, name: "Stage", note: "1-based stage number. Set it to 5 (with the header mirror) and the game reads `STAGE 05 — Star System Escape`, loads it, and flies it." },
|
|
FieldSpec { offset: 56, size: 4, kind: FieldKind::U32, confidence: Confidence::Refuted, name: "", note: "See +36 — refuted." },
|
|
FieldSpec { offset: 60, size: 4, kind: FieldKind::U32, confidence: Confidence::Unknown, name: "", note: "" },
|
|
FieldSpec { offset: 64, size: 4, kind: FieldKind::Raw, confidence: Confidence::Unknown, name: "", note: "The trailer's u32 carries the same value." },
|
|
FieldSpec { offset: 68, size: 54, kind: FieldKind::DevelopBlob, confidence: Confidence::Confirmed, name: "DevelopState", note: "One byte per Arsenal item. Index space is strings.tbl's item order, cut items included, closing at 53." },
|
|
];
|
|
|
|
/// Size of the GHAD progress block.
|
|
pub const GHAD_SIZE: usize = 122;
|
|
/// Records in the per-stage table.
|
|
pub const RECORD_COUNT: usize = 16;
|
|
/// `u32`s per record, after the `SHAB` tag.
|
|
pub const RECORD_FIELDS: usize = 5;
|
|
/// Length of the Arsenal development blob.
|
|
pub const DEVELOP_BLOB_LEN: usize = 54;
|
|
|
|
/// State of one Arsenal item in the development blob.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DevelopState {
|
|
/// `0` — not yet available.
|
|
Locked,
|
|
/// `2` — available to develop now. **Re-derived at load**, not stored state.
|
|
Developable,
|
|
/// `4` — owned.
|
|
Developed,
|
|
/// Anything else, which no save has yet shown.
|
|
Other(u8),
|
|
}
|
|
|
|
impl DevelopState {
|
|
pub fn from_byte(b: u8) -> Self {
|
|
match b {
|
|
0 => Self::Locked,
|
|
2 => Self::Developable,
|
|
4 => Self::Developed,
|
|
other => Self::Other(other),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One `SHAB` record — a per-stage result, record 0 being Stage 01.
|
|
///
|
|
/// These are **not** the UI's 20 save slots, which is what the count of 16
|
|
/// first suggested.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct StageRecord {
|
|
/// 🟡 difficulty.
|
|
pub a: u32,
|
|
/// ❔ a points figure — equals GHAD `+24`/`+28`, so not the displayed high
|
|
/// score (which is computed at display time and stored nowhere).
|
|
pub b: u32,
|
|
/// ✅ best clear time in milliseconds — 324 773 against the screen's
|
|
/// `05:24.77`.
|
|
pub best_time_ms: u32,
|
|
/// Windows FILETIME, high then low word.
|
|
pub time_hi: u32,
|
|
pub time_lo: u32,
|
|
}
|
|
|
|
impl StageRecord {
|
|
/// Whether the record holds a result at all.
|
|
pub fn is_used(&self) -> bool {
|
|
self.a != 0 || self.b != 0 || self.best_time_ms != 0
|
|
}
|
|
|
|
/// The record's FILETIME as 100 ns ticks since 1601, or `None` if unset.
|
|
pub fn filetime_ticks(&self) -> Option<u64> {
|
|
let t = (u64::from(self.time_hi) << 32) | u64::from(self.time_lo);
|
|
(t != 0).then_some(t)
|
|
}
|
|
}
|
|
|
|
/// The GDHA container header.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Header {
|
|
/// The header bytes, verbatim.
|
|
pub bytes: Vec<u8>,
|
|
}
|
|
|
|
/// A header word that mirrors a payload field, with where it came from.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct Mirror {
|
|
pub header_offset: usize,
|
|
pub value: u32,
|
|
/// The GHAD offset this copies, if it is a GHAD field.
|
|
pub ghad_offset: Option<usize>,
|
|
pub name: &'static str,
|
|
}
|
|
|
|
impl Header {
|
|
fn be32(&self, o: usize) -> u32 {
|
|
if o + 4 > self.bytes.len() {
|
|
return 0;
|
|
}
|
|
u32::from_be_bytes([
|
|
self.bytes[o],
|
|
self.bytes[o + 1],
|
|
self.bytes[o + 2],
|
|
self.bytes[o + 3],
|
|
])
|
|
}
|
|
|
|
/// The summary copy the in-game Details panel actually reads.
|
|
///
|
|
/// This is the trap the format doc records: a payload edit that leaves these
|
|
/// stale shows no change on the panel, which is *not* evidence that the
|
|
/// payload field was the wrong one.
|
|
pub fn summary(&self) -> Vec<Mirror> {
|
|
vec![
|
|
Mirror { header_offset: 0x14, value: self.be32(0x14), ghad_offset: Some(52), name: "Stage" },
|
|
Mirror { header_offset: 0x1c, value: self.be32(0x1c), ghad_offset: Some(24), name: "Points" },
|
|
Mirror { header_offset: 0x20, value: self.be32(0x20), ghad_offset: Some(4), name: "FlightTime" },
|
|
Mirror { header_offset: 0x24, value: self.be32(0x24), ghad_offset: Some(8), name: "ClearRatio" },
|
|
Mirror { header_offset: 0x28, value: self.be32(0x28), ghad_offset: Some(12), name: "TimesCleared" },
|
|
]
|
|
}
|
|
|
|
/// The three header fields derived from the payload — all that stands between
|
|
/// a parse and a hand-written save the title will load.
|
|
pub fn derived(&self) -> Vec<Mirror> {
|
|
vec![
|
|
Mirror { header_offset: 0x30, value: self.be32(0x30), ghad_offset: None, name: "deflate length + 10" },
|
|
Mirror { header_offset: 0x8c, value: u32::from(u16::from_be_bytes([self.bytes.get(0x8c).copied().unwrap_or(0), self.bytes.get(0x8d).copied().unwrap_or(0)])), ghad_offset: None, name: "payload length" },
|
|
Mirror { header_offset: 0x8e, value: self.be32(0x8e), ghad_offset: None, name: "adler32(payload)" },
|
|
]
|
|
}
|
|
}
|
|
|
|
/// A fully parsed save.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SaveGame {
|
|
pub header: Header,
|
|
/// The inflated payload, verbatim — the round-trip compares against this.
|
|
pub payload: Vec<u8>,
|
|
/// Current game phase, e.g. `GP_BUNK`. One of the title's screen ids.
|
|
pub phase: String,
|
|
/// The 122-byte progress block.
|
|
pub ghad: Vec<u8>,
|
|
/// The per-stage record table.
|
|
pub records: Vec<StageRecord>,
|
|
/// Trailer: `(name, tag, value)`.
|
|
pub trailer: (String, String, u32),
|
|
}
|
|
|
|
/// What went wrong reading a save.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum SaveError {
|
|
NotGdha,
|
|
NoZlibStream,
|
|
Inflate(String),
|
|
NotGdaa,
|
|
Truncated(&'static str),
|
|
/// A chunk tag was not where the serializer puts it.
|
|
BadTag { expected: &'static str, at: usize },
|
|
/// Bytes left over after the trailer — the layout is supposed to close
|
|
/// exactly, so this means the parse is wrong, not that the file has extras.
|
|
TrailingBytes(usize),
|
|
}
|
|
|
|
impl std::fmt::Display for SaveError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::NotGdha => write!(f, "not a GDHA container"),
|
|
Self::NoZlibStream => write!(f, "no zlib stream in the container"),
|
|
Self::Inflate(e) => write!(f, "inflate failed: {e}"),
|
|
Self::NotGdaa => write!(f, "payload is not GDAA"),
|
|
Self::Truncated(what) => write!(f, "payload truncated reading {what}"),
|
|
Self::BadTag { expected, at } => write!(f, "expected {expected} at {at:#x}"),
|
|
Self::TrailingBytes(n) => write!(f, "{n} bytes left after the trailer"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for SaveError {}
|
|
|
|
fn be32(b: &[u8], o: usize) -> Option<u32> {
|
|
b.get(o..o + 4)
|
|
.map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]))
|
|
}
|
|
|
|
/// Split a `GDHA` container into its header and inflated payload.
|
|
pub fn unwrap_container(raw: &[u8]) -> Result<(Header, Vec<u8>), SaveError> {
|
|
if raw.len() < 4 || &raw[0..4] != b"GDHA" {
|
|
return Err(SaveError::NotGdha);
|
|
}
|
|
let at = raw
|
|
.windows(2)
|
|
.position(|w| w == [0x78, 0xDA])
|
|
.ok_or(SaveError::NoZlibStream)?;
|
|
let mut out = Vec::new();
|
|
flate2::read::ZlibDecoder::new(&raw[at..])
|
|
.read_to_end(&mut out)
|
|
.map_err(|e| SaveError::Inflate(e.to_string()))?;
|
|
Ok((
|
|
Header {
|
|
bytes: raw[..at].to_vec(),
|
|
},
|
|
out,
|
|
))
|
|
}
|
|
|
|
/// Parse an inflated payload.
|
|
pub fn parse_payload(payload: &[u8]) -> Result<SaveGame, SaveError> {
|
|
if payload.len() < 4 || &payload[0..4] != b"GDAA" {
|
|
return Err(SaveError::NotGdaa);
|
|
}
|
|
let mut o = 4usize;
|
|
let nlen = be32(payload, o).ok_or(SaveError::Truncated("phase length"))? as usize;
|
|
o += 4;
|
|
let phase = payload
|
|
.get(o..o + nlen)
|
|
.map(|s| String::from_utf8_lossy(s).to_string())
|
|
.ok_or(SaveError::Truncated("phase name"))?;
|
|
o += nlen;
|
|
|
|
if payload.get(o..o + 4) != Some(b"GHAD") {
|
|
return Err(SaveError::BadTag { expected: "GHAD", at: o });
|
|
}
|
|
o += 4;
|
|
let ghad = payload
|
|
.get(o..o + GHAD_SIZE)
|
|
.ok_or(SaveError::Truncated("GHAD block"))?
|
|
.to_vec();
|
|
o += GHAD_SIZE;
|
|
|
|
let count = be32(payload, o).ok_or(SaveError::Truncated("record count"))? as usize;
|
|
o += 4;
|
|
let mut records = Vec::with_capacity(count);
|
|
for _ in 0..count {
|
|
if payload.get(o..o + 4) != Some(b"SHAB") {
|
|
return Err(SaveError::BadTag { expected: "SHAB", at: o });
|
|
}
|
|
o += 4;
|
|
let mut v = [0u32; RECORD_FIELDS];
|
|
for slot in v.iter_mut() {
|
|
*slot = be32(payload, o).ok_or(SaveError::Truncated("record"))?;
|
|
o += 4;
|
|
}
|
|
records.push(StageRecord {
|
|
a: v[0],
|
|
b: v[1],
|
|
best_time_ms: v[2],
|
|
time_hi: v[3],
|
|
time_lo: v[4],
|
|
});
|
|
}
|
|
|
|
let tlen = be32(payload, o).ok_or(SaveError::Truncated("trailer length"))? as usize;
|
|
o += 4;
|
|
let tname = payload
|
|
.get(o..o + tlen)
|
|
.map(|s| String::from_utf8_lossy(s).to_string())
|
|
.ok_or(SaveError::Truncated("trailer name"))?;
|
|
o += tlen;
|
|
let ttag = payload
|
|
.get(o..o + 4)
|
|
.map(|s| String::from_utf8_lossy(s).to_string())
|
|
.ok_or(SaveError::Truncated("trailer tag"))?;
|
|
o += 4;
|
|
let tval = be32(payload, o).ok_or(SaveError::Truncated("trailer value"))?;
|
|
o += 4;
|
|
if o != payload.len() {
|
|
return Err(SaveError::TrailingBytes(payload.len() - o));
|
|
}
|
|
|
|
Ok(SaveGame {
|
|
header: Header { bytes: Vec::new() },
|
|
payload: payload.to_vec(),
|
|
phase,
|
|
ghad,
|
|
records,
|
|
trailer: (tname, ttag, tval),
|
|
})
|
|
}
|
|
|
|
/// Parse a whole `savedata` file.
|
|
pub fn parse(raw: &[u8]) -> Result<SaveGame, SaveError> {
|
|
let (header, payload) = unwrap_container(raw)?;
|
|
let mut save = parse_payload(&payload)?;
|
|
save.header = header;
|
|
Ok(save)
|
|
}
|
|
|
|
impl SaveGame {
|
|
/// Re-serialize the payload.
|
|
///
|
|
/// This is the correctness check for the whole layout: the serializer writes
|
|
/// the struct field-by-field with no packing, so a correct parse must
|
|
/// reproduce the payload **byte for byte**. See [`SaveGame::round_trips`].
|
|
pub fn serialize_payload(&self) -> Vec<u8> {
|
|
let mut out = Vec::with_capacity(self.payload.len());
|
|
out.extend_from_slice(b"GDAA");
|
|
out.extend_from_slice(&(self.phase.len() as u32).to_be_bytes());
|
|
out.extend_from_slice(self.phase.as_bytes());
|
|
out.extend_from_slice(b"GHAD");
|
|
out.extend_from_slice(&self.ghad);
|
|
out.extend_from_slice(&(self.records.len() as u32).to_be_bytes());
|
|
for r in &self.records {
|
|
out.extend_from_slice(b"SHAB");
|
|
for v in [r.a, r.b, r.best_time_ms, r.time_hi, r.time_lo] {
|
|
out.extend_from_slice(&v.to_be_bytes());
|
|
}
|
|
}
|
|
let (name, tag, val) = &self.trailer;
|
|
out.extend_from_slice(&(name.len() as u32).to_be_bytes());
|
|
out.extend_from_slice(name.as_bytes());
|
|
out.extend_from_slice(tag.as_bytes());
|
|
out.extend_from_slice(&val.to_be_bytes());
|
|
out
|
|
}
|
|
|
|
/// Whether re-serializing reproduces the payload exactly.
|
|
pub fn round_trips(&self) -> bool {
|
|
self.serialize_payload() == self.payload
|
|
}
|
|
|
|
/// Read a GHAD field as an unsigned integer. `None` for the raw/blob fields.
|
|
pub fn ghad_value(&self, spec: &FieldSpec) -> Option<u64> {
|
|
match spec.size {
|
|
4 => be32(&self.ghad, spec.offset).map(u64::from),
|
|
8 => self
|
|
.ghad
|
|
.get(spec.offset..spec.offset + 8)
|
|
.map(|s| u64::from_be_bytes(s.try_into().unwrap())),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Raw bytes of a GHAD field.
|
|
pub fn ghad_bytes(&self, spec: &FieldSpec) -> &[u8] {
|
|
self.ghad
|
|
.get(spec.offset..spec.offset + spec.size)
|
|
.unwrap_or(&[])
|
|
}
|
|
|
|
/// The per-item Arsenal development state.
|
|
///
|
|
/// Index space is `strings.tbl`'s item order — the Arsenal's display order
|
|
/// *plus* the cut items only the localisation file lists — closing at 53.
|
|
/// `weapon.tbl`'s id list is **not** the index space; that it is also 54 long
|
|
/// is a coincidence, and the two agree only to index 32.
|
|
pub fn develop_state(&self) -> Vec<DevelopState> {
|
|
self.ghad
|
|
.get(68..68 + DEVELOP_BLOB_LEN)
|
|
.map(|b| b.iter().copied().map(DevelopState::from_byte).collect())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Convenience: the 1-based stage number (GHAD `+52`).
|
|
pub fn stage(&self) -> Option<u32> {
|
|
be32(&self.ghad, 52)
|
|
}
|
|
|
|
/// Convenience: the spendable points balance (GHAD `+24`).
|
|
pub fn points(&self) -> Option<u32> {
|
|
be32(&self.ghad, 24)
|
|
}
|
|
|
|
/// Convenience: total flight time in milliseconds (GHAD `+4`).
|
|
pub fn flight_time_ms(&self) -> Option<u32> {
|
|
be32(&self.ghad, 4)
|
|
}
|
|
|
|
/// Convenience: clear ratio in percent (GHAD `+8`).
|
|
pub fn clear_ratio_pct(&self) -> Option<u32> {
|
|
be32(&self.ghad, 8)
|
|
}
|
|
}
|
|
|
|
/// Format a millisecond count the way the game's panels do (`m:ss.mmm`).
|
|
pub fn fmt_millis(ms: u32) -> String {
|
|
let (m, rem) = (ms / 60_000, ms % 60_000);
|
|
format!("{}:{:02}.{:03}", m, rem / 1000, rem % 1000)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Build a payload with the exact chunk stream the serializer writes.
|
|
fn synth_payload(phase: &str, stage: u32, points: u32) -> Vec<u8> {
|
|
let mut ghad = vec![0u8; GHAD_SIZE];
|
|
ghad[4..8].copy_from_slice(&324_773u32.to_be_bytes()); // flight time
|
|
ghad[8..12].copy_from_slice(&5u32.to_be_bytes()); // clear ratio
|
|
ghad[24..28].copy_from_slice(&points.to_be_bytes());
|
|
ghad[52..56].copy_from_slice(&stage.to_be_bytes());
|
|
ghad[68] = 4; // item 0 developed
|
|
ghad[69] = 2; // item 1 developable
|
|
let mut p = Vec::new();
|
|
p.extend_from_slice(b"GDAA");
|
|
p.extend_from_slice(&(phase.len() as u32).to_be_bytes());
|
|
p.extend_from_slice(phase.as_bytes());
|
|
p.extend_from_slice(b"GHAD");
|
|
p.extend_from_slice(&ghad);
|
|
p.extend_from_slice(&(RECORD_COUNT as u32).to_be_bytes());
|
|
for i in 0..RECORD_COUNT {
|
|
p.extend_from_slice(b"SHAB");
|
|
let vals = if i == 0 {
|
|
[2u32, 4101, 324_773, 0x01DC_0000, 0x1234_5678]
|
|
} else {
|
|
[0, 0, 0, 0, 0]
|
|
};
|
|
for v in vals {
|
|
p.extend_from_slice(&v.to_be_bytes());
|
|
}
|
|
}
|
|
p.extend_from_slice(&4u32.to_be_bytes());
|
|
p.extend_from_slice(b"BUNK");
|
|
p.extend_from_slice(b"NETA");
|
|
p.extend_from_slice(&0x0915_0000u32.to_be_bytes());
|
|
p
|
|
}
|
|
|
|
#[test]
|
|
fn parses_the_chunk_stream() {
|
|
let p = synth_payload("GP_BUNK", 2, 4101);
|
|
let s = parse_payload(&p).unwrap();
|
|
assert_eq!(s.phase, "GP_BUNK");
|
|
assert_eq!(s.ghad.len(), GHAD_SIZE);
|
|
assert_eq!(s.records.len(), RECORD_COUNT);
|
|
assert_eq!(s.trailer, ("BUNK".into(), "NETA".into(), 0x0915_0000));
|
|
assert_eq!(s.stage(), Some(2));
|
|
assert_eq!(s.points(), Some(4101));
|
|
assert_eq!(s.flight_time_ms(), Some(324_773));
|
|
assert_eq!(s.clear_ratio_pct(), Some(5));
|
|
}
|
|
|
|
#[test]
|
|
fn round_trips_byte_identically() {
|
|
// The layout is closed, so a correct parse must reproduce the payload
|
|
// exactly. This is the property the whole spec rests on.
|
|
let p = synth_payload("GP_BUNK", 2, 4101);
|
|
let s = parse_payload(&p).unwrap();
|
|
assert!(s.round_trips());
|
|
assert_eq!(s.serialize_payload(), p);
|
|
}
|
|
|
|
#[test]
|
|
fn record_zero_is_stage_one() {
|
|
let s = parse_payload(&synth_payload("GP_BUNK", 2, 4101)).unwrap();
|
|
assert!(s.records[0].is_used());
|
|
assert!(!s.records[1].is_used());
|
|
assert_eq!(s.records[0].best_time_ms, 324_773);
|
|
assert!(s.records[0].filetime_ticks().is_some());
|
|
assert!(s.records[1].filetime_ticks().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn develop_blob_alphabet() {
|
|
let s = parse_payload(&synth_payload("GP_BUNK", 2, 4101)).unwrap();
|
|
let d = s.develop_state();
|
|
assert_eq!(d.len(), DEVELOP_BLOB_LEN);
|
|
assert_eq!(d[0], DevelopState::Developed);
|
|
assert_eq!(d[1], DevelopState::Developable);
|
|
assert_eq!(d[2], DevelopState::Locked);
|
|
}
|
|
|
|
#[test]
|
|
fn ghad_layout_is_closed() {
|
|
// 10*4 + 8 + 4*4 + 4 + 54 = 122, exactly what the file carries. If this
|
|
// ever fails the layout has grown a gap and the round-trip is a lie.
|
|
let total: usize = GHAD_LAYOUT.iter().map(|f| f.size).sum();
|
|
assert_eq!(total, GHAD_SIZE);
|
|
let mut at = 0;
|
|
for f in GHAD_LAYOUT {
|
|
assert_eq!(f.offset, at, "gap before {:#x}", f.offset);
|
|
at += f.size;
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_a_non_save() {
|
|
assert_eq!(parse(b"not a save at all").unwrap_err(), SaveError::NotGdha);
|
|
assert_eq!(parse_payload(b"XXXX").unwrap_err(), SaveError::NotGdaa);
|
|
}
|
|
|
|
#[test]
|
|
fn trailing_bytes_are_an_error_not_a_shrug() {
|
|
let mut p = synth_payload("GP_BUNK", 2, 4101);
|
|
p.push(0);
|
|
assert_eq!(parse_payload(&p).unwrap_err(), SaveError::TrailingBytes(1));
|
|
}
|
|
|
|
#[test]
|
|
fn millis_format_matches_the_panel() {
|
|
assert_eq!(fmt_millis(324_773), "5:24.773");
|
|
}
|
|
}
|