Files
Sylpheed/crates/sylpheed-formats/src/savegame.rs
Fabian Hamm ed54f95d54 style: rustfmt sweep -- 774 hunks across 154 files -> 0
`cargo fmt --all -- --check` has failed on every run in this repository's
history, identically on `main` and on every branch. This is #12.

Mechanical: `cargo fmt --all`, nothing else. 154 files, all `.rs`, no other
extension touched. `cargo check --workspace` exits 0 afterwards, so nothing
changed semantically.

ON THE ORDERING, WHICH WAS THE REAL QUESTION.

HANDOFF-2026-09-06 section 7 warns this is the expensive fix: a whole-tree
reformat before #7 and #8 return "would put a conflict in every file of 861
commits and make the reviews those items exist to enable unreadable".

That is measurably too pessimistic, and it had been reasoned rather than
tested. Measured here by three-way merging a rustfmt'd `main` against both
unmerged branches, file by file:

  file/branch pairs tested   32
  merges CLEAN               28
  merges CONFLICTING          4   (8 conflict hunks total)

    sylpheed-cli/src/main.rs      1 hunk
    sylpheed-export/src/check.rs  1
    sylpheed-export/src/screen.rs 4
    sylpheed-export/src/video.rs  2

All four are against `auto/frame-blend-draw-path` only;
`auto/port-p6-audio` does not conflict anywhere. The earlier framing --
154 dirty files, 133 that cannot collide, 21 that can, the collision set
carrying 147 of 774 hunks (19%) -- reproduces exactly. What it did not say
is that most of the 21 still merge cleanly, because rustfmt's edits and the
branches' edits rarely land on the same lines.

So the cost of sweeping now is 4 files and 8 hunks for one branch, against
a check that is otherwise red forever. Deliberately NOT folded into the
WASM PR: 154 reformatted files would make that one unreviewable.

Closes #12
2026-09-08 20:07:01 +02:00

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