This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/crates/sylpheed-formats/src/savegame.rs
MechaCat02 dc96bace6f formats: consolidate ui_layout onto the declaration table; add a savegame parser
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>
2026-08-17 18:59:27 +02:00

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");
}
}