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>
This commit is contained in:
@@ -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<usize>,
|
||||
},
|
||||
/// 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<usize>,
|
||||
/// 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<Vec<(usize, Vec<u8>)>> {
|
||||
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<u8>)], want: Option<usize>) -> Result<usize> {
|
||||
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<usize>) -> 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<u32>| 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::<Vec<_>>()
|
||||
.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<usize>,
|
||||
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::<Vec<_>>()
|
||||
.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<()> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
604
crates/sylpheed-formats/src/savegame.rs
Normal file
604
crates/sylpheed-formats/src/savegame.rs
Normal file
@@ -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
|
||||
//! <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");
|
||||
}
|
||||
}
|
||||
@@ -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 `<name>.t32` sprites and `<name>.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 `<name>.t32`
|
||||
//! sprites and `<name>.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<u32>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Parent element index (`+32`), or `None` for `0xffff_ffff`.
|
||||
pub parent: Option<usize>,
|
||||
/// 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<Keyframe>,
|
||||
/// `opt ` link to another record — the focused state of a button.
|
||||
pub focus_link: Option<String>,
|
||||
/// 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<Placement>,
|
||||
/// Every element, in the declaration table's back-to-front draw order.
|
||||
pub elements: Vec<Element>,
|
||||
/// Sprite name → (offset, size) of its `T8aD` child within the bundle.
|
||||
pub sprites: HashMap<String, (usize, usize)>,
|
||||
/// A guessed context from the sprite naming (e.g. `"tutorial"`), if any.
|
||||
pub context_hint: Option<String>,
|
||||
/// 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<Placement> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<Vec<Element>> {
|
||||
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<UiBuild> {
|
||||
let kids = ratc::parse(bundle)?;
|
||||
let mut sprites = HashMap::new();
|
||||
let mut records: HashMap<String, (usize, usize)> = 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<String, (usize, usize)>) -> Vec<Element> {
|
||||
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<UiBuild> {
|
||||
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<u8>,
|
||||
/// Names of the records actually drawn.
|
||||
pub drawn: Vec<String>,
|
||||
/// Indices of the elements actually drawn, in draw order.
|
||||
pub drawn: Vec<usize>,
|
||||
/// Elements skipped because their sprite could not be resolved or decoded.
|
||||
pub missing: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<ComposedScreen> {
|
||||
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<u8> {
|
||||
/// 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<Keyframe>)]) -> Vec<u8> {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
152
crates/sylpheed-formats/tests/savegame_samples.rs
Normal file
152
crates/sylpheed-formats/tests/savegame_samples.rs
Normal file
@@ -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<u8> {
|
||||
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<usize> = (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)");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user