diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index 6594924d..58a62e90 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -109,6 +109,63 @@ enum Commands { #[command(subcommand)] cmd: AudioCommands, }, + + /// UI screen tools — reassemble a screen from its RATC bundle + Screen { + #[command(subcommand)] + cmd: ScreenCommands, + }, + + /// Save file (`savedata`) tools + Save { + #[command(subcommand)] + cmd: SaveCommands, + }, +} + +#[derive(Subcommand)] +enum ScreenCommands { + /// List the screen builds in a UI pak, with their element counts + List { + /// Path to a `GP_*.pak` + pak: PathBuf, + }, + /// Print one build's element declaration table and resting placements + Info { + /// Path to a `GP_*.pak` + pak: PathBuf, + /// Which build (index into `screen list`); default = the largest + #[arg(long)] + build: Option, + }, + /// Composite one build to a PNG — the headless self-verify for the viewer + Render { + /// Path to a `GP_*.pak` + pak: PathBuf, + /// Output PNG + output: PathBuf, + /// Which build (index into `screen list`); default = the largest + #[arg(long)] + build: Option, + /// Draw the focused-state (`*f`) records over their base elements + #[arg(long)] + focus: bool, + /// Draw `loop*` sprite animations + #[arg(long)] + animated: bool, + }, +} + +#[derive(Subcommand)] +enum SaveCommands { + /// Parse a `savedata` file and print every field with its confidence + Info { + /// Path to a `savedata` file + file: PathBuf, + /// Also print the still-unidentified fields + #[arg(long)] + all: bool, + }, } #[derive(Subcommand)] @@ -237,9 +294,289 @@ async fn main() -> Result<()> { Commands::Audio { cmd } => match cmd { AudioCommands::Info { file } => cmd_audio_info(&file), }, + Commands::Screen { cmd } => match cmd { + ScreenCommands::List { pak } => cmd_screen_list(&pak), + ScreenCommands::Info { pak, build } => cmd_screen_info(&pak, build), + ScreenCommands::Render { pak, output, build, focus, animated } => { + cmd_screen_render(&pak, &output, build, focus, animated) + } + }, + Commands::Save { cmd } => match cmd { + SaveCommands::Info { file, all } => cmd_save_info(&file, all), + }, } } +// ── UI screens ─────────────────────────────────────────────────────────────── + +/// Every RATC entry of a UI pak that parses as a screen build, with its bytes. +fn screen_builds(pak: &Path) -> Result)>> { + use sylpheed_formats::{pak::PakArchive, ui_layout}; + let ar = PakArchive::open(pak).context("open pak")?; + let mut out = Vec::new(); + for (i, e) in ar.entries().iter().enumerate() { + let Ok(bytes) = ar.read(e) else { continue }; + if ui_layout::is_build(&bytes) { + out.push((i, bytes)); + } + } + Ok(out) +} + +/// Resolve `--build`: an explicit index into the build list, else the largest +/// build (a screen pak's biggest bundle is the full screen; the small ones are +/// language or context variants). +fn pick_build(builds: &[(usize, Vec)], want: Option) -> Result { + if builds.is_empty() { + anyhow::bail!("no screen builds in this pak"); + } + match want { + Some(i) if i < builds.len() => Ok(i), + Some(i) => anyhow::bail!("build {i} out of range (0..{})", builds.len()), + None => Ok(builds + .iter() + .enumerate() + .max_by_key(|(_, (_, b))| b.len()) + .map(|(i, _)| i) + .unwrap()), + } +} + +fn cmd_screen_list(pak: &Path) -> Result<()> { + use sylpheed_formats::ui_layout; + let builds = screen_builds(pak)?; + println!("{} screen build(s) in {}", builds.len(), pak.display()); + for (i, (entry, bytes)) in builds.iter().enumerate() { + match ui_layout::parse_build(bytes) { + Some(b) => println!( + " [{i}] entry {entry:<3} {:>8} B {}x{} {} elements, {} sprites{}{}", + bytes.len(), + b.design_w, + b.design_h, + b.elements.len(), + b.sprites.len(), + b.context_hint + .as_deref() + .map(|c| format!(" context={c}")) + .unwrap_or_default(), + if b.from_fallback { " (fallback)" } else { "" }, + ), + None => println!(" [{i}] entry {entry:<3} {:>8} B (unparsed)", bytes.len()), + } + } + Ok(()) +} + +fn cmd_screen_info(pak: &Path, want: Option) -> Result<()> { + use sylpheed_formats::ui_layout; + let builds = screen_builds(pak)?; + let idx = pick_build(&builds, want)?; + let bytes = &builds[idx].1; + let b = ui_layout::parse_build(bytes).context("build did not parse")?; + println!( + "build [{idx}] {}x{} {} elements {} sprites{}", + b.design_w, + b.design_h, + b.elements.len(), + b.sprites.len(), + if b.from_fallback { + " (recovered from .rat records — the declaration table was unusable)" + } else { + "" + } + ); + println!( + "{:<3} {:<30} {:>7} {:>8} {:>12} {:>4} rest / keyframes", + "#", "element", "parent", "kind", "pivot", "kf" + ); + for el in &b.elements { + // The resting pose is the max-dwell keyframe, not the first or the last. + let rest = match el.rest() { + None => "—".to_string(), + Some(k) if el.keyframes.len() == 1 => format!("({},{})", k.x, k.y), + Some(k) => { + // The final frame of a group carries no time — print it as `-` + // rather than inventing one. + let t = |t: Option| t.map(|v| v.to_string()).unwrap_or_else(|| "-".into()); + format!( + "rest ({},{}) t={} [{}]", + k.x, + k.y, + t(k.time), + el.keyframes + .iter() + .map(|f| format!("{}:{},{}", t(f.time), f.x, f.y)) + .collect::>() + .join(" ") + ) + } + }; + println!( + "{:<3} {:<30} {:>7} {:>8} {:>12} {:>4} {rest}", + el.index, + el.name, + el.parent.map(|p| p.to_string()).unwrap_or_else(|| "-".into()), + format!("{:#x}", el.kind), + format!("({},{})", el.pivot_x, el.pivot_y), + el.keyframes.len(), + ); + if let Some(link) = &el.focus_link { + println!("{:<3} {:<30} → focus {link}", "", ""); + } + } + Ok(()) +} + +fn cmd_screen_render( + pak: &Path, + output: &Path, + want: Option, + focus: bool, + animated: bool, +) -> Result<()> { + use sylpheed_formats::ui_layout::{self, ComposeOptions}; + let builds = screen_builds(pak)?; + let idx = pick_build(&builds, want)?; + let bytes = &builds[idx].1; + let b = ui_layout::parse_build(bytes).context("build did not parse")?; + let screen = ui_layout::compose( + &b, + bytes, + ComposeOptions { + include_focus: focus, + include_animated: animated, + }, + None, + ); + image::save_buffer( + output, + &screen.rgba, + screen.width, + screen.height, + image::ExtendedColorType::Rgba8, + ) + .context("write PNG")?; + println!( + "build [{idx}]: drew {}/{} elements → {} ({}x{})", + screen.drawn.len(), + b.elements.len(), + output.display(), + screen.width, + screen.height + ); + if !screen.missing.is_empty() { + println!(" sprites that did not resolve/decode: {:?}", screen.missing); + } + let undrawn: Vec<&str> = b + .elements + .iter() + .filter(|e| !screen.drawn.contains(&e.index)) + .map(|e| e.name.as_str()) + .collect(); + if !undrawn.is_empty() { + println!(" not drawn ({}): {undrawn:?}", undrawn.len()); + } + Ok(()) +} + +// ── save file ──────────────────────────────────────────────────────────────── + +fn cmd_save_info(file: &Path, all: bool) -> Result<()> { + use sylpheed_formats::savegame::{ + self, Confidence, DevelopState, FieldKind, GHAD_LAYOUT, + }; + let raw = std::fs::read(file).context("read save")?; + let save = savegame::parse(&raw).map_err(|e| anyhow::anyhow!("{e}"))?; + + let mark = |c: Confidence| match c { + Confidence::Confirmed => "OK ", + Confidence::Probable => "~ ", + Confidence::Unknown => "? ", + Confidence::Refuted => "REF", + }; + + println!( + "container : GDHA, {} B header + {} B deflate → {} B payload", + save.header.bytes.len(), + raw.len() - save.header.bytes.len(), + save.payload.len() + ); + println!( + "round-trip: {}", + if save.round_trips() { + "byte-identical" + } else { + "MISMATCH — the parse is wrong" + } + ); + println!("phase : {}", save.phase); + println!("\nGHAD progress block:"); + for f in GHAD_LAYOUT { + if !all && f.confidence == Confidence::Unknown && f.name.is_empty() { + continue; + } + let value = match f.kind { + FieldKind::Millis => save + .ghad_value(f) + .map(|v| format!("{v} ms ({})", savegame::fmt_millis(v as u32))), + FieldKind::Percent => save.ghad_value(f).map(|v| format!("{v} %")), + FieldKind::Raw | FieldKind::DevelopBlob => Some( + save.ghad_bytes(f) + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(" "), + ), + _ => save.ghad_value(f).map(|v| format!("{v}")), + } + .unwrap_or_else(|| "-".into()); + println!( + " {} +{:<3} {:<14} {}", + mark(f.confidence), + f.offset, + if f.name.is_empty() { "(unnamed)" } else { f.name }, + value + ); + if all && !f.note.is_empty() { + println!(" {}", f.note); + } + } + + let dev = save.develop_state(); + let owned = dev.iter().filter(|d| **d == DevelopState::Developed).count(); + let ready = dev + .iter() + .filter(|d| **d == DevelopState::Developable) + .count(); + println!( + "\nArsenal : {owned} developed, {ready} developable, {} locked (of {})", + dev.len() - owned - ready, + dev.len() + ); + + println!("\nper-stage records (SHAB — NOT the UI's save slots):"); + for (i, r) in save.records.iter().enumerate() { + if !r.is_used() { + continue; + } + println!( + " stage {:02} difficulty~{} points?{} best {} ", + i + 1, + r.a, + r.b, + savegame::fmt_millis(r.best_time_ms) + ); + } + + println!("\nheader summary (what the in-game Details panel reads):"); + for m in save.header.summary() { + println!(" +{:#04x} {:<14} {}", m.header_offset, m.name, m.value); + } + println!(" — a payload edit that leaves these stale shows no change on the panel,"); + println!(" which is not evidence that the payload field was the wrong one."); + Ok(()) +} + // ── audio info ─────────────────────────────────────────────────────────────── fn cmd_audio_info(file: &Path) -> Result<()> { diff --git a/crates/sylpheed-formats/src/lib.rs b/crates/sylpheed-formats/src/lib.rs index e824c3fc..79eeddbf 100644 --- a/crates/sylpheed-formats/src/lib.rs +++ b/crates/sylpheed-formats/src/lib.rs @@ -37,9 +37,12 @@ pub mod t8ad; // RATC nested resource bundle pub mod ratc; -/// UI screen layout (`.rat`) — reassemble a screen from its pak. +/// UI screen layout (`.rat` / RATC) — reassemble a screen from its pak. pub mod ui_layout; +/// The retail `savedata` file: GDHA container, zlib payload, chunk stream. +pub mod savegame; + // LSTA sprite list (inline T8aD frames) pub mod lsta; diff --git a/crates/sylpheed-formats/src/savegame.rs b/crates/sylpheed-formats/src/savegame.rs new file mode 100644 index 00000000..6905c0f5 --- /dev/null +++ b/crates/sylpheed-formats/src/savegame.rs @@ -0,0 +1,604 @@ +//! `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 +//! //535107D4/00000001/game01/savedata +//! ``` +//! +//! # Container +//! +//! ```text +//! 'GDHA' <146-byte header> +//! ``` +//! +//! 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 { + 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, +} + +/// 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, + 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 { + 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 { + 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, + /// 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, + /// The per-stage record table. + pub records: Vec, + /// 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 { + 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), 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + be32(&self.ghad, 52) + } + + /// Convenience: the spendable points balance (GHAD `+24`). + pub fn points(&self) -> Option { + be32(&self.ghad, 24) + } + + /// Convenience: total flight time in milliseconds (GHAD `+4`). + pub fn flight_time_ms(&self) -> Option { + be32(&self.ghad, 4) + } + + /// Convenience: clear ratio in percent (GHAD `+8`). + pub fn clear_ratio_pct(&self) -> Option { + 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 { + 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"); + } +} diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index bbde0e7b..63a71b86 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -1,59 +1,177 @@ -//! `.rat` UI-screen layout — reassemble a UI screen from its pak. +//! UI screen layout — reassemble a whole UI screen from one RATC bundle. //! -//! A UI screen ships as one pak (`GP_TITLE`, `GP_PAUSE_MENU`, …). Inside it, -//! each large [RATC](crate::ratc) bundle is one *(context × language)* **build** -//! of the screen, holding its `.t32` sprites and `.rat` layout -//! records side by side. Each `.rat` record is itself a RATC-tagged blob that -//! places one sprite; this module parses those records and composites the -//! sprites back into the screen image. +//! A UI screen ships as one pak (`GP_TITLE`, `GP_PAUSE_MENU`, `GP_HANGAR_ARSENAL`, +//! …). Inside it, each top-level [RATC](crate::ratc) bundle is one +//! *(context × language)* **build** of that screen, holding its `.t32` +//! sprites and `.rat` layout records side by side. //! -//! Format reverse-engineered in `docs/re/structures/ui-rat-layout.md` and -//! validated here against `GP_PAUSE_MENU.pak` / `GP_TITLE.pak`: the pause menu's -//! `pgpbtn00/01/04/15.rat` read X=226, Y=268/337/407/478 (the documented 70 px -//! pitch), and focus records land 42 px left / 8 px up of their base. +//! # The model +//! +//! The screen is **not** the set of `.rat` records — that was the first reading, +//! and it misses every element that has no `.rat` (the `eff*` glow frames, the +//! `deli*` dividers, `msg`). The screen is the bundle's own header: +//! +//! * the **element declaration table** at `0x20` (`0x14` = entry count, 60 bytes +//! per entry) lists every element **in back-to-front draw order**, with its +//! parent element index and its pivot; +//! * the **placement region** that follows gives each element a keyframe group — +//! a header of `(element index, keyframe count)` and then 40-byte keyframes of +//! scale / tint / X / Y / time. +//! +//! Three traps, each of which cost a wrong answer before it was measured (see +//! `docs/re/structures/ui-rat-layout.md`): +//! +//! * **X and Y are signed.** An Arsenal window animates in from X = −516; a +//! parser reading them as `u32` throws that element away as out of range. +//! * The trailing word of a keyframe is a **time**, not a fourth coordinate. +//! * **Neither the first nor the last keyframe is where the element sits.** A +//! group is an in → hold → out animation, so the resting position is the +//! **max-dwell** keyframe — the one with the longest gap to the next keyframe's +//! time. See [`Element::rest`]. +//! +//! Validated against the running game: the tutorial PAUSE menu and the title main +//! menu both rebuild pixel-accurately, and the Arsenal's eight category chips +//! land within ±2 px (the only free parameter being emulator window chrome). use crate::{ratc, t8ad}; use std::collections::HashMap; fn be32(b: &[u8], o: usize) -> u32 { + if o + 4 > b.len() { + return 0; + } u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) } -/// One sprite placement parsed from a `.rat` record. -#[derive(Debug, Clone)] -pub struct Placement { - /// The `.rat` record's own name (e.g. `pgpbtn00.rat`). - pub record: String, - /// The `.t32` sprite this record places (from record offset 0x20). - pub sprite: String, - /// Top-left position in the design space (X/Y from the placement block). - pub x: u32, - pub y: u32, +/// Offset of the element declaration table within a build bundle. +const DECL_TABLE_AT: usize = 0x20; +/// Bytes per declaration entry. +const DECL_ENTRY: usize = 60; +/// Bytes per placement keyframe. +const KEYFRAME: usize = 40; +/// The design space every screen is authored in. +const DESIGN_W: u32 = 1280; +const DESIGN_H: u32 = 720; + +/// One keyframe of an element's placement animation. +/// +/// The block is 40 bytes: +/// +/// ```text +/// +0 u32 ARGB fade colour — alpha ramps 0x00 → 0x80 → 0xd5 … over the group +/// +4 u32 0 +/// +8 u32 0 +/// +12 u32 0 +/// +16 u32 scale X, percent +/// +20 u32 scale Y, percent +/// +24 u32 tint (0xffff_ffff on every frame seen) +/// +28 i32 X ← signed +/// +32 i32 Y ← signed +/// +36 u32 time +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Keyframe { + /// The fade colour, ARGB. Its alpha is what ramps an element in. + pub fade: u32, /// Scale in percent (100 = 1:1). pub scale_x: u32, pub scale_y: u32, - /// RGBA tint (`0xffffffff` = untinted). + /// RGBA tint (`0xffff_ffff` = untinted). pub tint: u32, - /// A `*f.rat` focus-state record (draws the selection art). + /// Top-left position in the design space. **Signed** — off-screen animation + /// starts are negative. + pub x: i32, + pub y: i32, + /// Keyframe time, or `None` for the group's **last** frame. + /// + /// A group's data stops 4 bytes short of its final block's time slot — that + /// word is already the next group's element index. Reading it anyway is + /// where a stray `time = 1869640736` comes from, and it silently corrupts + /// the max-dwell pick in [`Element::rest`]. + pub time: Option, +} + +/// One element of a screen, in draw order. +#[derive(Debug, Clone)] +pub struct Element { + /// Index into [`UiBuild::elements`] — also the key the placement region uses. + pub index: usize, + /// Declared name, e.g. `pgp_ttrl_eff10.t32` or `pgp_ttrl_btn10.rat`. + pub name: String, + /// The `.t32` sprite this element draws, if one resolves. + pub sprite: Option, + /// Parent element index (`+32`), or `None` for `0xffff_ffff`. + pub parent: Option, + /// Kind flags (`+40`): `0` plain sprite, `1` has a parent, `0x4` a repeated + /// instance of a template, `0x3002` a button record. + pub kind: u32, + /// Declared pivot — for a `.t32` element this is exactly half the decoded + /// texture's dimensions (verified 7/7 on the tutorial bundle). + pub pivot_x: u32, + pub pivot_y: u32, + /// The element's placement keyframes, empty if the region declares none. + pub keyframes: Vec, + /// `opt ` link to another record — the focused state of a button. + pub focus_link: Option, + /// This element is itself a focused-state record. pub focused: bool, - /// A `loopN.rat` / keyframed record — its first frame is taken. + /// A `loopN` sprite animation rather than a placed element. pub animated: bool, } +impl Element { + /// Where the element actually rests on screen. + /// + /// A keyframe group is an in → hold → out animation, so the resting pose is + /// neither the first nor the last frame: it is the one that **dwells + /// longest** — the largest gap to the next keyframe's time. A single + /// keyframe is its own rest position. + pub fn rest(&self) -> Option<&Keyframe> { + match self.keyframes.len() { + 0 => None, + 1 => self.keyframes.first(), + n => { + let mut best = (0usize, 0u32); + for k in 0..n - 1 { + let (Some(t0), Some(t1)) = + (self.keyframes[k].time, self.keyframes[k + 1].time) + else { + continue; // the last frame carries no time + }; + let dwell = t1.saturating_sub(t0); + // `>=`, not `>`: on a tie take the LATER frame. A group is + // in → hold → out, so when two gaps are equal the second is + // the settled pose — `pgpmsg` holds 5 ticks at y=645 on the + // way in and 5 more at y=605 where it stays, and `>` picks + // the fly-through. + if dwell >= best.1 { + best = (k, dwell); + } + } + self.keyframes.get(best.0) + } + } + } +} + /// A parsed UI build: one screen layout (one context × language). pub struct UiBuild { /// Design-space dimensions, normally 1280×720. pub design_w: u32, pub design_h: u32, - /// Every `.rat` placement in draw order. - pub placements: Vec, + /// Every element, in the declaration table's back-to-front draw order. + pub elements: Vec, /// Sprite name → (offset, size) of its `T8aD` child within the bundle. pub sprites: HashMap, /// A guessed context from the sprite naming (e.g. `"tutorial"`), if any. pub context_hint: Option, + /// True when the declaration table was unusable and the build was recovered + /// by scanning `.rat` records instead — placements are then per-record and + /// `.rat`-less elements are missing. + pub from_fallback: bool, } -/// Whether `bundle` is a RATC build (has at least one `.rat` layout child). +/// Whether `bundle` is a RATC screen build (has at least one `.rat` layout child). pub fn is_build(bundle: &[u8]) -> bool { ratc::is_ratc(bundle) && ratc::parse(bundle).is_some_and(|kids| { @@ -62,96 +180,244 @@ pub fn is_build(bundle: &[u8]) -> bool { }) } -/// Parse one `.rat` record (a RATC-tagged placement blob) → a [`Placement`]. -fn parse_record(name: &str, rec: &[u8]) -> Option { - if rec.len() < 0x58 || rec[0..4] != *b"RATC" { +/// Trim a NUL-padded fixed-width name field. +fn fixed_name(b: &[u8]) -> String { + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + String::from_utf8_lossy(&b[..end]).trim().to_string() +} + +/// The `opt ` link inside a `.rat` record: a tag, a length, then the name. +fn opt_link(rec: &[u8]) -> Option { + let pos = rec.windows(4).position(|w| w == b"opt ")?; + let len = be32(rec, pos + 4) as usize; + if len == 0 || len > 64 || pos + 8 + len > rec.len() { return None; } - let dw = be32(rec, 0x18); - let dh = be32(rec, 0x1c); - if dw == 0 || dh == 0 || dw > 8192 || dh > 8192 { + let s = fixed_name(&rec[pos + 8..pos + 8 + len]); + (!s.is_empty()).then_some(s) +} + +/// The sprite a `.rat` record places: a NUL-terminated name at `0x20`. +/// +/// The field is **not** 16 bytes. Capping it there truncates every longer name — +/// `pgp_ttrl_title.t32` becomes `pgp_ttrl_title.t`, which then resolves against +/// nothing and silently drops the element from the composite. It runs up to the +/// pivot words at `0x50`. +fn record_sprite(rec: &[u8]) -> Option { + if rec.len() < 0x30 || rec[0..4] != *b"RATC" { return None; } - // Sprite name: NUL-terminated ASCII at 0x20 (up to 16 bytes). - let sname = { - let s = &rec[0x20..0x30.min(rec.len())]; - let end = s.iter().position(|&b| b == 0).unwrap_or(s.len()); - String::from_utf8_lossy(&s[..end]).trim().to_string() + let end = 0x50.min(rec.len()); + let s = fixed_name(&rec[0x20..end]); + (!s.is_empty()).then_some(s) +} + +/// Read the declaration table. `None` when it does not look like one. +fn parse_decls(bundle: &[u8]) -> Option> { + let count = be32(bundle, 0x14) as usize; + // Guard: this parser runs over ~2 900 bundles, most of which are not screen + // builds. A count that cannot fit is a mis-read, not a short table. + if count == 0 || count > 4096 || DECL_TABLE_AT + count * DECL_ENTRY > bundle.len() { + return None; + } + let mut elements = Vec::with_capacity(count); + for i in 0..count { + let e = &bundle[DECL_TABLE_AT + i * DECL_ENTRY..DECL_TABLE_AT + (i + 1) * DECL_ENTRY]; + let name = fixed_name(&e[..28]); + // Every real declaration names something; a table of blanks means we are + // reading past the header of a bundle that has no declaration table. + if name.is_empty() { + return None; + } + let parent = match be32(e, 32) { + u32::MAX => None, + p if (p as usize) < count => Some(p as usize), + _ => None, + }; + let lname = name.to_ascii_lowercase(); + elements.push(Element { + index: i, + name, + sprite: None, + parent, + kind: be32(e, 40), + pivot_x: be32(e, 48), + pivot_y: be32(e, 52), + keyframes: Vec::new(), + focus_link: None, + focused: lname.ends_with("f.rat") || lname.ends_with("f.t32"), + animated: lname.contains("loop"), + }); + } + Some(elements) +} + +/// Read the placement region that follows the declaration table, filling in each +/// element's keyframe group. +fn parse_placements(bundle: &[u8], elements: &mut [Element]) { + let count = elements.len(); + let mut pos = DECL_TABLE_AT + count * DECL_ENTRY; + for _ in 0..count { + if pos + 8 > bundle.len() { + break; + } + let idx = be32(bundle, pos) as usize; + let frames = be32(bundle, pos + 4) as usize; + if idx >= count || frames == 0 || frames > 4096 { + break; + } + // Group header is (index, count) then one lead-in word; blocks follow. + let first = pos + 12; + // The region is packed so that the next group's header sits 4 bytes + // inside the last block — i.e. the group owns `frames * 40 - 4` bytes of + // block data, and the final block's time field is not its own. + let group_end = first + frames * KEYFRAME - 4; + let mut group = Vec::with_capacity(frames); + for k in 0..frames { + let blk = first + k * KEYFRAME; + if blk + 36 > bundle.len() || blk + 36 > group_end { + break; + } + group.push(Keyframe { + fade: be32(bundle, blk), + scale_x: be32(bundle, blk + 16), + scale_y: be32(bundle, blk + 20), + tint: be32(bundle, blk + 24), + x: be32(bundle, blk + 28) as i32, + y: be32(bundle, blk + 32) as i32, + // Only a block wholly inside the group carries a time. + time: (blk + 40 <= group_end).then(|| be32(bundle, blk + 36)), + }); + } + elements[idx].keyframes = group; + pos = group_end; + } +} + +/// Parse a build bundle into its elements and sprite table. +pub fn parse_build(bundle: &[u8]) -> Option { + let kids = ratc::parse(bundle)?; + let mut sprites = HashMap::new(); + let mut records: HashMap = HashMap::new(); + for c in &kids { + let end = (c.offset + c.size).min(bundle.len()); + if c.kind == "T8aD" { + sprites.insert(c.name.clone(), (c.offset, end - c.offset)); + } else if c.name.to_ascii_lowercase().ends_with(".rat") { + records.insert(c.name.clone(), (c.offset, end - c.offset)); + } + } + + let (mut elements, from_fallback) = match parse_decls(bundle) { + Some(mut els) => { + parse_placements(bundle, &mut els); + (els, false) + } + // No usable declaration table: recover what the `.rat` records alone can + // say. Elements without a record (eff*/deli*/msg) are then missing, so + // callers are told via `from_fallback`. + None => (fallback_elements(bundle, &records), true), }; - if sname.is_empty() { + + // Resolve each element to the sprite it draws, and pick up its focus link. + for el in &mut elements { + if let Some(&(off, size)) = records.get(&el.name) { + let rec = &bundle[off..off + size]; + el.sprite = record_sprite(rec); + el.focus_link = opt_link(rec); + } else if sprites.contains_key(&el.name) { + el.sprite = Some(el.name.clone()); + } + } + + if elements.is_empty() { return None; } - // Placement block: [scaleX=100, scaleY=100, tint, X, Y] — the first such run - // whose X/Y fall inside the design space (records are tag-driven/variable, so - // this anchor is more robust than a fixed offset). See the format doc. - let mut placement = None; + // The design space is stated by any `.rat` record; every screen seen is + // 1280×720, which is also the fallback. + let (design_w, design_h) = records + .values() + .find_map(|&(off, size)| { + let r = &bundle[off..off + size]; + let (w, h) = (be32(r, 0x18), be32(r, 0x1c)); + (w > 0 && h > 0 && w <= 8192 && h <= 8192).then_some((w, h)) + }) + .unwrap_or((DESIGN_W, DESIGN_H)); + + let context_hint = sprites + .keys() + .find_map(|n| n.contains("ttrl").then(|| "tutorial".to_string())); + + Some(UiBuild { + design_w, + design_h, + elements, + sprites, + context_hint, + from_fallback, + }) +} + +/// Recover elements from the `.rat` records when the declaration table is +/// unreadable: each record becomes an element with a single keyframe. +fn fallback_elements(bundle: &[u8], records: &HashMap) -> Vec { + let mut names: Vec<&String> = records.keys().collect(); + names.sort_by_key(|n| records[*n].0); // file order stands in for draw order + let mut out = Vec::new(); + for name in names { + let (off, size) = records[name]; + let rec = &bundle[off..off + size]; + let Some((tint, x, y)) = scan_placement_block(rec) else { + continue; + }; + let lname = name.to_ascii_lowercase(); + out.push(Element { + index: out.len(), + name: name.clone(), + sprite: record_sprite(rec), + parent: None, + kind: 0, + pivot_x: be32(rec, 0x50), + pivot_y: be32(rec, 0x54), + keyframes: vec![Keyframe { + fade: 0xffff_ffff, + scale_x: 100, + scale_y: 100, + tint, + x, + y, + time: Some(0), + }], + focus_link: opt_link(rec), + focused: lname.ends_with("f.rat"), + animated: lname.contains("loop"), + }); + } + for (i, el) in out.iter_mut().enumerate() { + el.index = i; + } + out +} + +/// Find a `[scaleX=100, scaleY=100, tint, X, Y]` run inside a `.rat` record. +/// Records are tag-driven and variable-length, so this anchor beats a fixed +/// offset — it is only used on the fallback path. +fn scan_placement_block(rec: &[u8]) -> Option<(u32, i32, i32)> { let mut o = 0x58; while o + 20 <= rec.len() { if be32(rec, o) == 100 && be32(rec, o + 4) == 100 { - let (tint, x, y) = (be32(rec, o + 8), be32(rec, o + 12), be32(rec, o + 16)); - if x < dw && y < dh { - placement = Some((tint, x, y)); - break; + let (tint, x, y) = ( + be32(rec, o + 8), + be32(rec, o + 12) as i32, + be32(rec, o + 16) as i32, + ); + if x > -4096 && x < 8192 && y > -4096 && y < 8192 { + return Some((tint, x, y)); } } o += 4; } - let (tint, x, y) = placement?; - let lname = name.to_ascii_lowercase(); - Some(Placement { - record: name.to_string(), - sprite: sname, - x, - y, - scale_x: 100, - scale_y: 100, - tint, - focused: lname.ends_with("f.rat"), - animated: lname.contains("loop"), - }) -} - -/// Parse a build bundle into its placements and sprite table. -pub fn parse_build(bundle: &[u8]) -> Option { - let kids = ratc::parse(bundle)?; - let mut sprites = HashMap::new(); - let mut placements = Vec::new(); - for c in &kids { - let lname = c.name.to_ascii_lowercase(); - let end = (c.offset + c.size).min(bundle.len()); - if c.kind == "T8aD" { - sprites.insert(c.name.clone(), (c.offset, end - c.offset)); - } else if lname.ends_with(".rat") { - if let Some(p) = parse_record(&c.name, &bundle[c.offset..end]) { - placements.push(p); - } - } - } - if placements.is_empty() { - return None; - } - let (design_w, design_h) = placements - .iter() - .find_map(|_| { - // design dims are constant across records; re-read the first record - kids.iter() - .find(|c| c.name.to_ascii_lowercase().ends_with(".rat")) - .map(|c| { - let r = &bundle[c.offset..(c.offset + c.size).min(bundle.len())]; - (be32(r, 0x18), be32(r, 0x1c)) - }) - }) - .unwrap_or((1280, 720)); - let context_hint = sprites - .keys() - .find_map(|n| n.contains("ttrl").then(|| "tutorial".to_string())); - Some(UiBuild { - design_w, - design_h, - placements, - sprites, - context_hint, - }) + None } /// A composited screen image ready to display. @@ -160,72 +426,136 @@ pub struct ComposedScreen { pub height: u32, /// Row-major RGBA8. pub rgba: Vec, - /// Names of the records actually drawn. - pub drawn: Vec, + /// Indices of the elements actually drawn, in draw order. + pub drawn: Vec, + /// Elements skipped because their sprite could not be resolved or decoded. + pub missing: Vec, } -/// Composite a build into its screen image. -/// -/// Draws every base placement (title + menu items). Animated `loop*` records are -/// skipped (they're decorations without a static position); `*f` focus records -/// are skipped unless `include_focus`. Sprites are alpha-blended at their -/// top-left with their tint applied. +/// What to include when compositing. +#[derive(Debug, Clone, Copy)] +pub struct ComposeOptions { + /// Draw `*f` focused-state records over their base elements. + pub include_focus: bool, + /// Draw `loop*` sprite animations. + pub include_animated: bool, +} + +impl Default for ComposeOptions { + fn default() -> Self { + Self { + include_focus: false, + include_animated: false, + } + } +} + +/// Composite a build into its screen image, using the default options. pub fn compose_build(bundle: &[u8], include_focus: bool) -> Option { let build = parse_build(bundle)?; + Some(compose( + &build, + bundle, + ComposeOptions { + include_focus, + ..Default::default() + }, + None, + )) +} + +/// Composite a parsed build. +/// +/// `visible`, when given, selects elements by index — the viewer uses it for +/// per-element toggles. Elements are drawn in declaration order, which is the +/// screen's own back-to-front order. +pub fn compose( + build: &UiBuild, + bundle: &[u8], + opts: ComposeOptions, + visible: Option<&[bool]>, +) -> ComposedScreen { let (w, h) = (build.design_w, build.design_h); - // A dim backdrop stands in for the PRMD dim-quad + live 3D scene. - let mut canvas = vec![0u8; (w * h * 4) as usize]; + // A dim backdrop stands in for the PRMD dim-quad + the live 3D scene behind + // an in-mission screen. + let mut canvas = vec![0u8; (w as usize) * (h as usize) * 4]; for px in canvas.chunks_exact_mut(4) { px.copy_from_slice(&[14, 14, 20, 255]); } let mut drawn = Vec::new(); - for p in &build.placements { - if p.animated || (p.focused && !include_focus) { + let mut missing = Vec::new(); + for el in &build.elements { + if let Some(v) = visible { + if !v.get(el.index).copied().unwrap_or(true) { + continue; + } + } + if (el.animated && !opts.include_animated) || (el.focused && !opts.include_focus) { continue; } - let Some(&(off, size)) = build.sprites.get(&p.sprite) else { + let Some(kf) = el.rest() else { continue }; + let Some(sprite) = el.sprite.as_ref() else { + continue; + }; + let Some(&(off, size)) = build.sprites.get(sprite) else { + missing.push(sprite.clone()); continue; }; let Some(img) = t8ad::parse(&bundle[off..off + size]) else { + missing.push(sprite.clone()); continue; }; - blit(&mut canvas, w, h, &img, p); - drawn.push(p.record.clone()); + blit(&mut canvas, w, h, &img, kf); + drawn.push(el.index); } - Some(ComposedScreen { + ComposedScreen { width: w, height: h, rgba: canvas, drawn, - }) + missing, + } } -/// Alpha-blend one sprite onto the canvas at its placement, with tint + scale. -fn blit(canvas: &mut [u8], cw: u32, ch: u32, img: &t8ad::T8adImage, p: &Placement) { +/// Alpha-blend one sprite onto the canvas at a keyframe's placement, with tint +/// and scale. Placements may be negative or run off the edge, so both axes clip. +fn blit(canvas: &mut [u8], cw: u32, ch: u32, img: &t8ad::T8adImage, kf: &Keyframe) { let (sw, sh) = (img.width, img.height); if sw == 0 || sh == 0 { return; } - let (dw, dh) = (sw * p.scale_x / 100, sh * p.scale_y / 100); + let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x }; + let sy_pct = if kf.scale_y == 0 { 100 } else { kf.scale_y }; + let dw = (sw * sx_pct / 100).max(1); + let dh = (sh * sy_pct / 100).max(1); let (tr, tg, tb, ta) = ( - (p.tint >> 24) & 0xff, - (p.tint >> 16) & 0xff, - (p.tint >> 8) & 0xff, - p.tint & 0xff, + (kf.tint >> 24) & 0xff, + (kf.tint >> 16) & 0xff, + (kf.tint >> 8) & 0xff, + kf.tint & 0xff, ); for oy in 0..dh { - let ty = p.y + oy; - if ty >= ch { + let ty = kf.y + oy as i32; + if ty < 0 { + continue; + } + if ty >= ch as i32 { break; } let syi = (oy * sh / dh).min(sh - 1); for ox in 0..dw { - let tx = p.x + ox; - if tx >= cw { + let tx = kf.x + ox as i32; + if tx < 0 { + continue; + } + if tx >= cw as i32 { break; } let sxi = (ox * sw / dw).min(sw - 1); let si = ((syi * sw + sxi) * 4) as usize; + if si + 3 >= img.rgba.len() { + continue; + } let sr = img.rgba[si] as u32 * tr / 255; let sg = img.rgba[si + 1] as u32 * tg / 255; let sb = img.rgba[si + 2] as u32 * tb / 255; @@ -233,7 +563,7 @@ fn blit(canvas: &mut [u8], cw: u32, ch: u32, img: &t8ad::T8adImage, p: &Placemen if sa == 0 { continue; } - let di = ((ty * cw + tx) * 4) as usize; + let di = ((ty as u32 * cw + tx as u32) * 4) as usize; for (k, sc) in [sr, sg, sb].into_iter().enumerate() { let dc = canvas[di + k] as u32; canvas[di + k] = ((sc * sa + dc * (255 - sa)) / 255) as u8; @@ -247,36 +577,160 @@ fn blit(canvas: &mut [u8], cw: u32, ch: u32, img: &t8ad::T8adImage, p: &Placemen mod tests { use super::*; - /// A synthetic `.rat` record: RATC header, sprite name at 0x20, a - /// `[100,100,tint,X,Y]` placement block. - fn synth_record(sprite: &str, x: u32, y: u32) -> Vec { + /// A synthetic build bundle: RATC magic, entry count at 0x14, a declaration + /// table at 0x20, then a placement region. + fn synth_build(decls: &[(&str, u32, u32, u32, u32)], groups: &[(usize, Vec)]) -> Vec { + let count = decls.len(); + let mut b = vec![0u8; DECL_TABLE_AT + count * DECL_ENTRY]; + b[0..4].copy_from_slice(b"RATC"); + b[0x14..0x18].copy_from_slice(&(count as u32).to_be_bytes()); + for (i, &(name, parent, kind, px, py)) in decls.iter().enumerate() { + let at = DECL_TABLE_AT + i * DECL_ENTRY; + b[at..at + name.len()].copy_from_slice(name.as_bytes()); + b[at + 32..at + 36].copy_from_slice(&parent.to_be_bytes()); + b[at + 40..at + 44].copy_from_slice(&kind.to_be_bytes()); + b[at + 48..at + 52].copy_from_slice(&px.to_be_bytes()); + b[at + 52..at + 56].copy_from_slice(&py.to_be_bytes()); + } + for (idx, frames) in groups { + let start = b.len(); + b.extend_from_slice(&(*idx as u32).to_be_bytes()); + b.extend_from_slice(&(frames.len() as u32).to_be_bytes()); + b.resize(start + 12, 0); // header + one lead-in word + for kf in frames { + b.extend_from_slice(&kf.fade.to_be_bytes()); + b.extend_from_slice(&[0u8; 12]); + b.extend_from_slice(&kf.scale_x.to_be_bytes()); + b.extend_from_slice(&kf.scale_y.to_be_bytes()); + b.extend_from_slice(&kf.tint.to_be_bytes()); + b.extend_from_slice(&kf.x.to_be_bytes()); + b.extend_from_slice(&kf.y.to_be_bytes()); + b.extend_from_slice(&kf.time.unwrap_or(0).to_be_bytes()); + } + // The next group's header overlaps the last block's time slot. + b.truncate(b.len() - 4); + } + b + } + + fn kf(x: i32, y: i32, time: u32) -> Keyframe { + Keyframe { + fade: 0xffff_ffff, + scale_x: 100, + scale_y: 100, + tint: 0xffff_ffff, + x, + y, + time: Some(time), + } + } + + #[test] + fn reads_declaration_table_in_draw_order() { + let b = synth_build( + &[ + ("pgpeff02.t32", u32::MAX, 0, 204, 60), + ("pgpeff02a.t32", 0, 1, 100, 40), + ("pgpbtn00.rat", u32::MAX, 0x3002, 64, 16), + ], + &[], + ); + let els = parse_decls(&b).unwrap(); + assert_eq!(els.len(), 3); + assert_eq!(els[0].name, "pgpeff02.t32"); + assert_eq!(els[0].parent, None); + // `+32` is a parent element index: the `…a` variant names its base. + assert_eq!(els[1].parent, Some(0)); + assert_eq!(els[1].kind, 1); + assert_eq!(els[2].kind, 0x3002); + assert_eq!((els[0].pivot_x, els[0].pivot_y), (204, 60)); + } + + #[test] + fn placement_x_and_y_are_signed() { + // The Arsenal window animates in from X = -516; read as u32 it would be + // ~4.29 billion and the element would be discarded. + let b = synth_build( + &[("aswindow.t32", u32::MAX, 0, 0, 0)], + &[(0, vec![kf(-516, 40, 0), kf(120, 40, 30)])], + ); + let mut els = parse_decls(&b).unwrap(); + parse_placements(&b, &mut els); + assert_eq!(els[0].keyframes.len(), 2); + assert_eq!(els[0].keyframes[0].x, -516); + assert_eq!(els[0].keyframes[1].x, 120); + } + + #[test] + fn rest_is_the_max_dwell_keyframe_not_the_first_or_last() { + // in (t=0) → hold (t=10..90) → out (t=100): the resting pose is the + // middle frame, which both "first" and "last" would get wrong. + let el = Element { + index: 0, + name: "x.t32".into(), + sprite: None, + parent: None, + kind: 0, + pivot_x: 0, + pivot_y: 0, + keyframes: vec![kf(-500, 10, 0), kf(226, 268, 10), kf(900, 268, 90)], + focus_link: None, + focused: false, + animated: false, + }; + let r = el.rest().unwrap(); + assert_eq!((r.x, r.y), (226, 268)); + } + + #[test] + fn single_keyframe_is_its_own_rest() { + let el = Element { + index: 0, + name: "x.t32".into(), + sprite: None, + parent: None, + kind: 0, + pivot_x: 0, + pivot_y: 0, + keyframes: vec![kf(546, 288, 0)], + focus_link: None, + focused: false, + animated: false, + }; + assert_eq!(el.rest().map(|k| (k.x, k.y)), Some((546, 288))); + } + + #[test] + fn rejects_a_bundle_with_no_declaration_table() { + let mut b = vec![0u8; 0x400]; + b[0..4].copy_from_slice(b"RATC"); + b[0x14..0x18].copy_from_slice(&999_999u32.to_be_bytes()); // cannot fit + assert!(parse_decls(&b).is_none()); + } + + #[test] + fn opt_link_reads_the_focus_record() { + let mut r = vec![0u8; 0x60]; + r[0..4].copy_from_slice(b"RATC"); + r[0x20..0x20 + 12].copy_from_slice(b"pgpbtn00.t32"); + r.extend_from_slice(b"opt "); + r.extend_from_slice(&13u32.to_be_bytes()); + r.extend_from_slice(b"pgpbtn00f.rat"); + assert_eq!(record_sprite(&r).as_deref(), Some("pgpbtn00.t32")); + assert_eq!(opt_link(&r).as_deref(), Some("pgpbtn00f.rat")); + } + + #[test] + fn fallback_scans_records_when_the_table_is_unusable() { + // The old `.rat`-only reading, kept as a recovery path. let mut r = vec![0u8; 0x58]; r[0..4].copy_from_slice(b"RATC"); r[0x18..0x1c].copy_from_slice(&1280u32.to_be_bytes()); r[0x1c..0x20].copy_from_slice(&720u32.to_be_bytes()); - let nb = sprite.as_bytes(); - r[0x20..0x20 + nb.len()].copy_from_slice(nb); - for v in [100u32, 100, 0xffff_ffff, x, y] { + r[0x20..0x20 + 12].copy_from_slice(b"pgpbtn00.t32"); + for v in [100u32, 100, 0xffff_ffff, 226, 268] { r.extend_from_slice(&v.to_be_bytes()); } - r - } - - #[test] - fn parses_placement_block() { - let r = synth_record("pgpbtn00.t32", 226, 268); - let p = parse_record("pgpbtn00.rat", &r).unwrap(); - assert_eq!(p.sprite, "pgpbtn00.t32"); - assert_eq!((p.x, p.y), (226, 268)); - assert_eq!(p.tint, 0xffff_ffff); - assert!(!p.focused); - } - - #[test] - fn flags_focus_and_rejects_out_of_range() { - let f = parse_record("pgpbtn00f.rat", &synth_record("ring.t32", 184, 260)).unwrap(); - assert!(f.focused); - // X beyond design space → no placement found. - assert!(parse_record("bad.rat", &synth_record("x.t32", 9000, 10)).is_none()); + assert_eq!(scan_placement_block(&r), Some((0xffff_ffff, 226, 268))); } } diff --git a/crates/sylpheed-formats/tests/savegame_samples.rs b/crates/sylpheed-formats/tests/savegame_samples.rs new file mode 100644 index 00000000..3f0349db --- /dev/null +++ b/crates/sylpheed-formats/tests/savegame_samples.rs @@ -0,0 +1,152 @@ +//! The save parser against the three real saves committed under `docs/re/captures`. +//! +//! These need no disc and no emulator — the samples are in the repo — so this +//! runs in plain `cargo test`. The load-bearing assertion is the **byte-identical +//! round-trip**: the title's serializer writes its struct field by field with no +//! packing, so a correct parse must reproduce the payload exactly. Anything less +//! means a field has the wrong width or the chunk stream has an unread gap. + +use std::path::{Path, PathBuf}; + +use sylpheed_formats::savegame::{self, Confidence, DevelopState, GHAD_SIZE, RECORD_COUNT}; + +fn captures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/re/captures") +} + +fn sample(name: &str) -> Vec { + std::fs::read(captures().join(name)).unwrap_or_else(|e| panic!("read {name}: {e}")) +} + +const SAMPLES: [&str; 3] = [ + "savedata-game02-samestate.bin", + "savedata-game03-developed-mg1.bin", + "savedata-stage02-5pct.bin", +]; + +#[test] +fn every_sample_parses_and_round_trips_byte_identically() { + for name in SAMPLES { + let raw = sample(name); + let save = savegame::parse(&raw).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(save.payload.len(), 545, "{name}: the whole save is 545 bytes"); + assert_eq!(save.ghad.len(), GHAD_SIZE, "{name}"); + assert_eq!(save.records.len(), RECORD_COUNT, "{name}"); + assert!( + save.round_trips(), + "{name}: re-serializing did not reproduce the payload" + ); + } +} + +#[test] +fn phase_is_one_of_the_titles_screen_ids() { + for name in SAMPLES { + let save = savegame::parse(&sample(name)).unwrap(); + assert!( + save.phase.starts_with("GP_"), + "{name}: phase {:?} is not a GP_* screen id", + save.phase + ); + } +} + +#[test] +fn trailer_closes_the_stream() { + for name in SAMPLES { + let save = savegame::parse(&sample(name)).unwrap(); + let (tname, tag, _) = &save.trailer; + assert_eq!(tname, "BUNK", "{name}"); + assert_eq!(tag, "NETA", "{name}"); + } +} + +/// The develop differential: the `game03` save was taken after developing exactly +/// one Arsenal weapon (Light Machine Gun MG I, 4000 P) from the `game02` state. +/// Exactly three things moved, and this is what gave the blob its alphabet. +#[test] +fn developing_one_weapon_moves_points_ratio_and_two_blob_entries() { + let before = savegame::parse(&sample("savedata-game02-samestate.bin")).unwrap(); + let after = savegame::parse(&sample("savedata-game03-developed-mg1.bin")).unwrap(); + + // Points fell by the item's 4000 P cost — and +28 did NOT move, which is what + // separates the spendable balance from its twin. + let (p0, p1) = (before.points().unwrap(), after.points().unwrap()); + assert_eq!(p0 - p1, 4000, "Points should fall by the 4000 P cost"); + let twin = |s: &savegame::SaveGame| { + savegame::GHAD_LAYOUT + .iter() + .find(|f| f.offset == 28) + .and_then(|f| s.ghad_value(f)) + .unwrap() + }; + assert_eq!(twin(&before), twin(&after), "+28 must not move on a spend"); + + // The clear ratio counts collection, not only stages. + assert_eq!( + after.clear_ratio_pct().unwrap(), + before.clear_ratio_pct().unwrap() + 1 + ); + + // Blob: the bought item became developed, its successor became developable. + let (b, a) = (before.develop_state(), after.develop_state()); + let moved: Vec = (0..b.len()).filter(|&i| b[i] != a[i]).collect(); + assert_eq!(moved.len(), 2, "exactly two blob entries move, got {moved:?}"); + assert_eq!(a[moved[0]], DevelopState::Developed); + assert_eq!(a[moved[1]], DevelopState::Developable); +} + +/// The two saves of the same state differ only in the header (its FILETIME and +/// the uninitialised pointer padding) — the payload is a pure function of game +/// state. +#[test] +fn payload_is_a_pure_function_of_game_state() { + let a = savegame::parse(&sample("savedata-game02-samestate.bin")).unwrap(); + let b = savegame::parse(&sample("savedata-stage02-5pct.bin")).unwrap(); + assert_eq!( + a.payload, b.payload, + "the same state saved twice must deflate to the same payload" + ); +} + +/// The summary copy the Details panel reads must agree with the payload it +/// mirrors — the two are written together, and a disagreement is what a +/// payload-only edit produces. +#[test] +fn header_summary_agrees_with_the_payload_it_mirrors() { + for name in SAMPLES { + let save = savegame::parse(&sample(name)).unwrap(); + for m in save.header.summary() { + let Some(goff) = m.ghad_offset else { continue }; + let spec = savegame::GHAD_LAYOUT + .iter() + .find(|f| f.offset == goff) + .expect("mirror names a real GHAD field"); + let payload_value = save.ghad_value(spec).unwrap(); + assert_eq!( + u64::from(m.value), + payload_value, + "{name}: header {:#x} ({}) disagrees with GHAD +{}", + m.header_offset, + m.name, + goff + ); + } + } +} + +/// The eleven still-unknown GHAD fields are a documented fact, not an oversight. +/// If a future session names one, this count moves — deliberately. +#[test] +fn unknown_fields_are_still_declared_unknown() { + let unknown = savegame::GHAD_LAYOUT + .iter() + .filter(|f| f.confidence == Confidence::Unknown) + .count(); + let refuted = savegame::GHAD_LAYOUT + .iter() + .filter(|f| f.confidence == Confidence::Refuted) + .count(); + assert_eq!(unknown, 7, "unknown GHAD fields"); + assert_eq!(refuted, 2, "fields tested and refuted (+36, +56)"); +} diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index ef254c7e..e66d3898 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -26,8 +26,15 @@ impl Plugin for ViewerUiPlugin { app.insert_resource(FileBrowserState::default()); // Run after the iso_loader chain so we see the frame's final state. app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet)); - app.add_systems(Update, draw_game_data_ui.after(IsoLoaderSystemSet)); - app.add_systems(Update, draw_ships_ui.after(IsoLoaderSystemSet)); + // The standalone browser windows are native-only (their loaders are), + // and so are their draw systems — so the registration has to be gated + // too, or the wasm build fails on an undefined name (`just ci` runs + // `cargo check --target wasm32-unknown-unknown`). + #[cfg(not(target_arch = "wasm32"))] + { + app.add_systems(Update, draw_game_data_ui.after(IsoLoaderSystemSet)); + app.add_systems(Update, draw_ships_ui.after(IsoLoaderSystemSet)); + } } }