viewer: UI Screens and Save File browsers (View menu)

Two new floating browsers, both fed only by static data — the disc for screens,
a savedata file for saves — following the existing Game Data / Ships pattern
(request event → worker thread → IsoLoaderMsg → draw system).

UI Screens
  Pick a screen pak and one of its builds (a build = one context × language
  variant; GP_PAUSE_MENU holds six) and see the screen reassembled at 1280×720
  beside the element list it was derived from, so a wrong placement shows up as
  a wrong picture and the row next to it says why. Per-element visibility
  toggles, focused-state and loop-animation toggles, and an inspector showing
  each element's sprite, parent link, kind flags, pivot and keyframe count.

  Compositing decodes ~30 T8aD sprites, which is far too slow for the UI
  thread, so it runs on a worker under the same generation gate the XPR path
  uses — a toggle mid-decode discards the stale result rather than racing it.

  The panel deliberately surfaces two things it would be easy to hide: sprites
  that named a child the bundle does not hold, and builds recovered through the
  .rat fallback (where elements without a record are simply absent).

Save File
  Opens a savedata file (it lives in the emulator's content tree, not on the
  disc, so this is a file dialog rather than a disc path). Shows the container,
  the GHAD block with every field coloured by its actual confidence, the
  Arsenal develop blob, the per-stage SHAB records, and the header summary.

  Three things the panel states rather than glosses: the byte-identical
  round-trip, which is the check the whole layout rests on; that SHAB records
  are per-stage results and NOT the UI's save slots; and that the header
  summary is what the Details panel reads, so a payload edit that leaves it
  stale proves nothing about the field that was edited. Fields tested and
  refuted (+36, +56) are shown as refuted rather than dropped.

Also gates draw_game_data_ui / draw_ships_ui at their registration site. Both
are #[cfg(not(wasm32))] but were registered unconditionally, so the wasm leg of
`just ci` failed on an undefined name. NOTE: that leg still does not build, for
a pre-existing and unrelated reason — the workspace pins tokio with features =
["full"], which pulls mio, which refuses to compile for wasm32. Fixing that
means restructuring tokio's features per target and is left alone here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-08-17 19:15:15 +02:00
parent dc96bace6f
commit af0796c835
2 changed files with 994 additions and 3 deletions

View File

@@ -658,6 +658,143 @@ impl Default for ShipBrowser {
}
}
// ── UI screens (View ▸ Screens) ──────────────────────────────────────────────
/// One UI screen pak on the disc (`dat/GP_TITLE.pak`), and how many screen
/// builds it holds. A build is one *(context × language)* variant of the screen.
#[derive(Clone)]
pub struct ScreenPak {
/// Disc path, e.g. `dat/GP_PAUSE_MENU.pak`.
pub path: String,
/// Display name, e.g. `PAUSE MENU`.
pub label: String,
/// One entry per build: (pak entry index, byte size).
pub builds: Vec<(usize, usize)>,
}
/// One element row shown beside the composited screen.
#[derive(Clone)]
pub struct ScreenElementRow {
pub index: usize,
pub name: String,
pub sprite: Option<String>,
pub parent: Option<usize>,
pub kind: u32,
pub pivot: (u32, u32),
/// Resting placement — the max-dwell keyframe, not the first or last.
pub rest: Option<(i32, i32)>,
pub keyframes: usize,
pub focus_link: Option<String>,
pub drawn: bool,
}
/// A composited screen plus everything the inspector shows beside it.
pub struct ComposedScreenResult {
pub pak: String,
pub build: usize,
pub design: (u32, u32),
pub image: Option<ImageRgba>,
pub elements: Vec<ScreenElementRow>,
/// Sprites that named a child the bundle does not hold, or that failed to
/// decode — surfaced rather than silently dropped.
pub missing: Vec<String>,
/// The build was recovered from `.rat` records because the declaration table
/// was unusable, so `.rat`-less elements are absent.
pub from_fallback: bool,
pub error: Option<String>,
}
/// State of the UI screen browser.
#[derive(Resource, Default)]
pub struct ScreenBrowser {
pub open: bool,
pub loading: bool,
pub loaded: bool,
pub filter: String,
pub paks: Vec<ScreenPak>,
/// Which pak is selected, and which of its builds.
pub selected: Option<usize>,
pub build: usize,
/// Draw the `*f` focused-state records over their base elements.
pub show_focus: bool,
/// Draw `loop*` sprite animations.
pub show_animated: bool,
/// Per-element visibility for the current build (index-aligned).
pub hidden: Vec<bool>,
pub composed: Option<ComposedScreenResult>,
/// egui-side texture cache for the composite: (generation, handle).
pub tex: Option<(u64, egui::TextureHandle)>,
/// Bumped per compose request so a stale worker result is discarded.
pub generation: u64,
pub composing: bool,
pub selected_element: Option<usize>,
}
/// Ask the loader to find the UI screen paks on the disc.
#[derive(Event, Default)]
pub struct RequestScreenCatalog;
/// Ask the loader to reassemble + composite one screen build.
#[derive(Event)]
pub struct RequestScreenCompose {
pub pak: String,
pub build: usize,
pub focus: bool,
pub animated: bool,
/// Element indices to leave out (the per-element toggles).
pub hidden: Vec<bool>,
}
// ── Save files (View ▸ Save File) ────────────────────────────────────────────
/// One GHAD field, flattened for display with its confidence.
#[derive(Clone)]
pub struct SaveFieldRow {
pub offset: usize,
pub name: String,
pub value: String,
pub confidence: sylpheed_formats::savegame::Confidence,
pub note: String,
}
/// A parsed save, flattened for the inspector.
pub struct SaveResult {
pub path: String,
pub error: Option<String>,
pub header_len: usize,
pub deflate_len: usize,
pub payload_len: usize,
/// Whether re-serializing reproduced the payload byte for byte. This is the
/// check the whole layout rests on, so it is shown, not assumed.
pub round_trips: bool,
pub phase: String,
pub fields: Vec<SaveFieldRow>,
/// (index, state) for the 54-byte Arsenal development blob.
pub develop: Vec<(usize, sylpheed_formats::savegame::DevelopState)>,
/// Used per-stage records: (stage number, difficulty?, points?, best ms).
pub records: Vec<(usize, u32, u32, u32)>,
/// The summary copy the in-game Details panel reads: (header offset, name,
/// value, the payload value it should mirror).
pub summary: Vec<(usize, String, u32, Option<u64>)>,
}
/// State of the save-file inspector.
#[derive(Resource, Default)]
pub struct SaveBrowser {
pub open: bool,
pub loading: bool,
pub result: Option<Box<SaveResult>>,
/// Show the fields that are still unidentified.
pub show_unknown: bool,
}
/// Ask the loader to open + parse a `savedata` file. `None` = show a file
/// dialog.
#[derive(Event, Default)]
pub struct RequestSaveOpen {
pub path: Option<PathBuf>,
}
/// Ask the loader to scan the stage containers and build the ship catalog.
#[derive(Event, Default)]
pub struct RequestShipCatalog;
@@ -769,6 +906,16 @@ enum IsoLoaderMsg {
ShipCatalogLoaded(Vec<ShipRow>),
/// The decoded game-data tables for the browser.
GameDataLoaded(Box<GameSnapshot>),
/// The list of UI screen paks found on the disc.
ScreenCatalogLoaded(Vec<ScreenPak>),
/// One screen build reassembled + composited. `generation` drops a stale
/// result when the user has since picked another screen or toggled an option.
ScreenComposed {
generation: u64,
composed: Box<ComposedScreenResult>,
},
/// A `savedata` file parsed (or the parse error, verbatim).
SaveLoaded(Box<SaveResult>),
/// User dismissed the file dialog — not an error.
Cancelled,
Error(String),
@@ -988,13 +1135,18 @@ impl Plugin for IsoLoaderPlugin {
.init_resource::<VoiceLibrary>()
.init_resource::<GameData>()
.init_resource::<ShipBrowser>()
.init_resource::<ScreenBrowser>()
.init_resource::<SaveBrowser>()
.add_event::<RequestSubtitles>()
.add_event::<RequestVoice>()
.add_event::<RequestAudio>()
.add_event::<RequestVoiceLibrary>()
.add_event::<RequestGameData>()
.add_event::<RequestShipCatalog>()
.add_event::<RequestShipRender>();
.add_event::<RequestShipRender>()
.add_event::<RequestScreenCatalog>()
.add_event::<RequestScreenCompose>()
.add_event::<RequestSaveOpen>();
#[cfg(not(target_arch = "wasm32"))]
{
@@ -1026,6 +1178,9 @@ impl Plugin for IsoLoaderPlugin {
handle_game_data_request,
handle_ship_catalog_request,
handle_ship_render_request,
handle_screen_catalog_request,
handle_screen_compose_request,
handle_save_open_request,
)
.chain()
.in_set(IsoLoaderSystemSet),
@@ -1880,6 +2035,8 @@ fn poll_loader_channel(
mut voice_lib: ResMut<VoiceLibrary>,
mut game_data: ResMut<GameData>,
mut ships: ResMut<ShipBrowser>,
mut screens: ResMut<ScreenBrowser>,
mut saves: ResMut<SaveBrowser>,
) {
let receiver = channels.receiver.lock().unwrap();
loop {
@@ -2009,9 +2166,32 @@ fn poll_loader_channel(
ships.loaded = !rows.is_empty();
ships.rows = rows;
}
Ok(IsoLoaderMsg::ScreenCatalogLoaded(paks)) => {
screens.loading = false;
screens.loaded = !paks.is_empty();
screens.paks = paks;
}
Ok(IsoLoaderMsg::ScreenComposed {
generation,
composed,
}) => {
// Drop a stale composite: the user has since picked another
// screen or flipped a toggle.
if generation == screens.generation {
screens.composing = false;
screens.hidden.resize(composed.elements.len(), false);
screens.composed = Some(*composed);
screens.tex = None; // force the egui texture to be rebuilt
}
}
Ok(IsoLoaderMsg::SaveLoaded(result)) => {
saves.loading = false;
saves.result = Some(result);
}
Ok(IsoLoaderMsg::Cancelled) => {
iso_state.loading = false;
browser.loading = false;
saves.loading = false;
}
Ok(IsoLoaderMsg::Error(msg)) => {
error!("ISO loader: {}", msg);
@@ -3882,6 +4062,329 @@ fn build_game_snapshot(source: &SourceKind) -> Option<GameSnapshot> {
Some(GameSnapshot { weapons, craft, vessels, characters, missions, arsenal, flights })
}
// ── UI screens ───────────────────────────────────────────────────────────────
/// `dat/GP_PAUSE_MENU.pak` → `PAUSE MENU`.
#[cfg(not(target_arch = "wasm32"))]
fn screen_label(path: &str) -> String {
path.rsplit('/')
.next()
.unwrap_or(path)
.trim_end_matches(".pak")
.trim_start_matches("GP_")
.replace('_', " ")
}
/// Handles a [`RequestScreenCatalog`]: finds which `GP_*.pak` files actually
/// hold screen builds, off-thread.
#[cfg(not(target_arch = "wasm32"))]
fn handle_screen_catalog_request(
mut events: EventReader<RequestScreenCatalog>,
iso_state: Res<IsoState>,
// The listed file set is the browser's, and it covers both source kinds.
browser: Res<crate::ui::FileBrowserState>,
channels: Res<IsoChannels>,
mut screens: ResMut<ScreenBrowser>,
) {
if events.read().next().is_none() {
return;
}
if screens.loaded || screens.loading {
return;
}
screens.loading = true;
let source = iso_state.source_kind.clone();
let files = browser.files.clone();
let sender = channels.sender.clone();
std::thread::spawn(move || {
let paks = build_screen_catalog(&source, &files);
let _ = sender.send(IsoLoaderMsg::ScreenCatalogLoaded(paks));
});
}
/// Open every `GP_*.pak` and record which entries parse as a screen build.
#[cfg(not(target_arch = "wasm32"))]
fn build_screen_catalog(source: &SourceKind, files: &[String]) -> Vec<ScreenPak> {
use sylpheed_formats::ui_layout;
let mut out = Vec::new();
let mut candidates: Vec<&String> = files
.iter()
.filter(|f| {
let l = f.to_ascii_lowercase();
l.ends_with(".pak") && l.rsplit('/').next().is_some_and(|n| n.starts_with("gp_"))
})
.collect();
candidates.sort();
for path in candidates {
let Ok(ar) = read_pak_archive_blocking(source, path) else {
continue;
};
let mut builds = Vec::new();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
if ui_layout::is_build(&bytes) {
builds.push((i, bytes.len()));
}
}
if !builds.is_empty() {
out.push(ScreenPak {
path: path.clone(),
label: screen_label(path),
builds,
});
}
}
out
}
/// Handles a [`RequestScreenCompose`]: reassembles one screen build and
/// composites it off-thread. Decoding ~30 T8aD sprites at 1280×720 is far too
/// slow to do on the UI thread, and every option toggle re-composites — so this
/// follows the same generation-gated pattern as the XPR path.
#[cfg(not(target_arch = "wasm32"))]
fn handle_screen_compose_request(
mut events: EventReader<RequestScreenCompose>,
iso_state: Res<IsoState>,
channels: Res<IsoChannels>,
mut screens: ResMut<ScreenBrowser>,
) {
let Some(req) = events.read().last() else {
return;
};
screens.generation = screens.generation.wrapping_add(1);
screens.composing = true;
let generation = screens.generation;
let (pak, build, focus, animated, hidden) = (
req.pak.clone(),
req.build,
req.focus,
req.animated,
req.hidden.clone(),
);
let source = iso_state.source_kind.clone();
let sender = channels.sender.clone();
std::thread::spawn(move || {
let composed = compose_screen(&source, &pak, build, focus, animated, &hidden);
let _ = sender.send(IsoLoaderMsg::ScreenComposed {
generation,
composed: Box::new(composed),
});
});
}
#[cfg(not(target_arch = "wasm32"))]
fn compose_screen(
source: &SourceKind,
pak: &str,
build_idx: usize,
focus: bool,
animated: bool,
hidden: &[bool],
) -> ComposedScreenResult {
use sylpheed_formats::ui_layout::{self, ComposeOptions};
let fail = |e: String| ComposedScreenResult {
pak: pak.to_string(),
build: build_idx,
design: (1280, 720),
image: None,
elements: Vec::new(),
missing: Vec::new(),
from_fallback: false,
error: Some(e),
};
let ar = match read_pak_archive_blocking(source, pak) {
Ok(a) => a,
Err(e) => return fail(e),
};
let bundles: Vec<Vec<u8>> = ar
.entries()
.iter()
.filter_map(|e| ar.read(e).ok())
.filter(|b| ui_layout::is_build(b))
.collect();
let Some(bundle) = bundles.get(build_idx) else {
return fail(format!("build {build_idx} not in {pak}"));
};
let Some(build) = ui_layout::parse_build(bundle) else {
return fail("build did not parse".into());
};
// `visible` is the inverse of the UI's `hidden`; a short list means "all
// visible", which is what a freshly-selected screen has.
let visible: Vec<bool> = build
.elements
.iter()
.map(|e| !hidden.get(e.index).copied().unwrap_or(false))
.collect();
let screen = ui_layout::compose(
&build,
bundle,
ComposeOptions {
include_focus: focus,
include_animated: animated,
},
Some(&visible),
);
let elements = build
.elements
.iter()
.map(|el| ScreenElementRow {
index: el.index,
name: el.name.clone(),
sprite: el.sprite.clone(),
parent: el.parent,
kind: el.kind,
pivot: (el.pivot_x, el.pivot_y),
rest: el.rest().map(|k| (k.x, k.y)),
keyframes: el.keyframes.len(),
focus_link: el.focus_link.clone(),
drawn: screen.drawn.contains(&el.index),
})
.collect();
ComposedScreenResult {
pak: pak.to_string(),
build: build_idx,
design: (screen.width, screen.height),
image: Some(ImageRgba {
width: screen.width,
height: screen.height,
rgba: screen.rgba,
}),
elements,
missing: screen.missing,
from_fallback: build.from_fallback,
error: None,
}
}
// ── Save files ───────────────────────────────────────────────────────────────
/// Handles a [`RequestSaveOpen`]: picks a file if none was given, then parses it.
/// A save is 545 bytes, so the parse itself is trivial — the thread is for the
/// blocking file dialog.
#[cfg(not(target_arch = "wasm32"))]
fn handle_save_open_request(
mut events: EventReader<RequestSaveOpen>,
channels: Res<IsoChannels>,
mut saves: ResMut<SaveBrowser>,
) {
let Some(req) = events.read().last() else {
return;
};
if saves.loading {
return;
}
saves.loading = true;
saves.open = true;
let want = req.path.clone();
let sender = channels.sender.clone();
std::thread::spawn(move || {
let path = match want {
Some(p) => Some(p),
None => rfd::FileDialog::new()
.set_title("Open a Project Sylpheed savedata file")
.pick_file(),
};
let Some(path) = path else {
let _ = sender.send(IsoLoaderMsg::Cancelled);
return;
};
let _ = sender.send(IsoLoaderMsg::SaveLoaded(Box::new(read_save(&path))));
});
}
#[cfg(not(target_arch = "wasm32"))]
fn read_save(path: &Path) -> SaveResult {
use sylpheed_formats::savegame::{self, FieldKind, GHAD_LAYOUT};
let display = path.display().to_string();
let fail = |e: String| SaveResult {
path: display.clone(),
error: Some(e),
header_len: 0,
deflate_len: 0,
payload_len: 0,
round_trips: false,
phase: String::new(),
fields: Vec::new(),
develop: Vec::new(),
records: Vec::new(),
summary: Vec::new(),
};
let raw = match std::fs::read(path) {
Ok(b) => b,
Err(e) => return fail(e.to_string()),
};
let save = match savegame::parse(&raw) {
Ok(s) => s,
Err(e) => return fail(e.to_string()),
};
let fields = GHAD_LAYOUT
.iter()
.map(|f| {
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| v.to_string()),
}
.unwrap_or_else(|| "-".into());
SaveFieldRow {
offset: f.offset,
name: if f.name.is_empty() {
"(unidentified)".into()
} else {
f.name.into()
},
value,
confidence: f.confidence,
note: f.note.into(),
}
})
.collect();
let develop = save.develop_state().into_iter().enumerate().collect();
let records = save
.records
.iter()
.enumerate()
.filter(|(_, r)| r.is_used())
.map(|(i, r)| (i + 1, r.a, r.b, r.best_time_ms))
.collect();
let summary = save
.header
.summary()
.into_iter()
.map(|m| {
let payload = m.ghad_offset.and_then(|off| {
GHAD_LAYOUT
.iter()
.find(|f| f.offset == off)
.and_then(|f| save.ghad_value(f))
});
(m.header_offset, m.name.to_string(), m.value, payload)
})
.collect();
SaveResult {
path: display,
error: None,
header_len: save.header.bytes.len(),
deflate_len: raw.len() - save.header.bytes.len(),
payload_len: save.payload.len(),
round_trips: save.round_trips(),
phase: save.phase.clone(),
fields,
develop,
records,
summary,
}
}
/// Handles a [`RequestShipCatalog`]: scans the stage containers and reconstructs
/// the assemblable ship catalog off-thread.
#[cfg(not(target_arch = "wasm32"))]

View File

@@ -12,8 +12,9 @@ use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{
AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview,
MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir,
RequestOpenIso, RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestVoiceLibrary,
ShipBrowser, SkyboxPreview, TextPreview, TexturePreview,
RequestOpenIso, RequestSaveOpen, RequestScreenCatalog, RequestScreenCompose,
RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestVoiceLibrary, SaveBrowser,
ScreenBrowser, ShipBrowser, SkyboxPreview, TextPreview, TexturePreview,
VideoPreview, VoiceLibrary, IsoLoaderSystemSet,
};
use crate::ViewerState;
@@ -34,6 +35,8 @@ impl Plugin for ViewerUiPlugin {
{
app.add_systems(Update, draw_game_data_ui.after(IsoLoaderSystemSet));
app.add_systems(Update, draw_ships_ui.after(IsoLoaderSystemSet));
app.add_systems(Update, draw_screens_ui.after(IsoLoaderSystemSet));
app.add_systems(Update, draw_save_ui.after(IsoLoaderSystemSet));
}
}
}
@@ -148,6 +151,8 @@ struct UiEvents<'w> {
voice_lib: EventWriter<'w, RequestVoiceLibrary>,
game_data: EventWriter<'w, RequestGameData>,
ships: EventWriter<'w, RequestShipCatalog>,
screens: EventWriter<'w, RequestScreenCatalog>,
save: EventWriter<'w, RequestSaveOpen>,
}
fn draw_viewer_ui(
@@ -217,6 +222,18 @@ fn draw_viewer_ui(
events.ships.send_default();
ui.close_menu();
}
if ui.button("🖼 UI Screens…").clicked() {
// Reassembles a screen from its RATC bundle: the declaration
// table is the draw list, the placement region the layout.
events.screens.send_default();
ui.close_menu();
}
if ui.button("💾 Save File…").clicked() {
// A save is not on the disc — it lives in the emulator's
// content tree, so this opens a file dialog.
events.save.send_default();
ui.close_menu();
}
});
ui.menu_button("Help", |ui| {
@@ -1806,3 +1823,474 @@ fn draw_ships_ui(
});
ships.open &= open;
}
// ── UI Screens browser (View ▸ Screens) ──────────────────────────────────────
/// Reassemble a whole UI screen from its RATC bundle and show it at 1280×720,
/// beside the element list it was built from.
///
/// The point of showing both is that the screen is *derived*: every sprite sits
/// where the bundle's placement region says it does, so a wrong placement is
/// visible as a wrong picture, and the element row next to it says why.
#[cfg(not(target_arch = "wasm32"))]
fn draw_screens_ui(
mut contexts: EguiContexts,
mut screens: ResMut<ScreenBrowser>,
mut requests: EventReader<RequestScreenCatalog>,
mut compose: EventWriter<RequestScreenCompose>,
) {
if requests.read().next().is_some() {
screens.open = true;
}
if !screens.open {
return;
}
let ctx = contexts.ctx_mut().clone();
let mut open = true;
// Set when a control changes; recomposing is a worker round-trip, so it is
// requested once at the end rather than from inside the widget closures.
let mut recompose = false;
let mut pick: Option<(usize, usize)> = None;
egui::Window::new("🖼 UI Screens")
.default_width(1120.0)
.default_height(720.0)
.open(&mut open)
.show(&ctx, |ui| {
if screens.loading {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Scanning the screen paks…");
});
ctx.request_repaint();
return;
}
if !screens.loaded {
ui.label("Open a game source first (File ▸ Open…).");
return;
}
ui.horizontal(|ui| {
ui.label("🔍");
ui.text_edit_singleline(&mut screens.filter);
if !screens.filter.is_empty() && ui.small_button("").clicked() {
screens.filter.clear();
}
});
ui.separator();
let filter = screens.filter.to_lowercase();
egui::SidePanel::left("screen_list")
.resizable(true)
.default_width(240.0)
.show_inside(ui, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
for (pi, pak) in screens.paks.iter().enumerate() {
if !filter.is_empty() && !pak.label.to_lowercase().contains(&filter) {
continue;
}
// One pak per screen; each build inside it is one
// (context × language) variant, so they are listed
// separately rather than collapsed.
egui::CollapsingHeader::new(&pak.label)
.default_open(pak.builds.len() == 1)
.show(ui, |ui| {
for (bi, (entry, size)) in pak.builds.iter().enumerate() {
let selected = screens.selected == Some(pi)
&& screens.build == bi;
let label = format!(
"build {bi} · entry {entry} · {} KB",
size / 1024
);
if ui.selectable_label(selected, label).clicked() {
pick = Some((pi, bi));
}
}
});
}
});
});
egui::CentralPanel::default().show_inside(ui, |ui| {
ui.horizontal(|ui| {
if ui.checkbox(&mut screens.show_focus, "Focused states").changed() {
recompose = true;
}
if ui
.checkbox(&mut screens.show_animated, "Loop animations")
.changed()
{
recompose = true;
}
if screens.composing {
ui.spinner();
ctx.request_repaint();
}
});
ui.separator();
// Split the borrow: the element grid mutates `hidden` and the
// image block mutates `tex` while `composed` is read.
let ScreenBrowser {
composed,
tex,
hidden,
generation,
selected_element,
..
} = &mut *screens;
let generation = *generation;
let Some(result) = composed.as_ref() else {
ui.label("Pick a screen build on the left.");
return;
};
if let Some(err) = &result.error {
ui.colored_label(egui::Color32::from_rgb(224, 86, 122), err);
return;
}
if result.from_fallback {
ui.colored_label(
egui::Color32::from_rgb(224, 168, 86),
"⚠ Recovered from .rat records — the declaration table was \
unusable, so elements without a record are missing.",
);
}
if !result.missing.is_empty() {
ui.colored_label(
egui::Color32::from_rgb(224, 168, 86),
format!("⚠ sprites that did not resolve: {:?}", result.missing),
);
}
// The composite, scaled to fit.
if let Some(img) = &result.image {
let need = tex.as_ref().map(|(g, _)| *g) != Some(generation);
if need {
let ci = egui::ColorImage::from_rgba_unmultiplied(
[img.width as usize, img.height as usize],
&img.rgba,
);
let handle =
ctx.load_texture("ui_screen", ci, egui::TextureOptions::LINEAR);
*tex = Some((generation, handle));
}
if let Some((_, handle)) = &*tex {
let avail = ui.available_width().min(880.0);
let scale = (avail / img.width as f32).min(1.0);
ui.add(egui::Image::new(handle).fit_to_exact_size(egui::vec2(
img.width as f32 * scale,
img.height as f32 * scale,
)));
}
}
ui.separator();
let drawn = result.elements.iter().filter(|e| e.drawn).count();
ui.label(format!(
"{}×{} · {} of {} elements drawn (declaration order = back to front)",
result.design.0,
result.design.1,
drawn,
result.elements.len()
));
egui::ScrollArea::vertical()
.max_height(220.0)
.auto_shrink([false, false])
.show(ui, |ui| {
egui::Grid::new("g_screen_elems")
.striped(true)
.num_columns(7)
.show(ui, |ui| {
for h in
["", "#", "Element", "Parent", "Kind", "Pivot", "Rest / kf"]
{
ui.strong(h);
}
ui.end_row();
for el in &result.elements {
let mut vis =
!hidden.get(el.index).copied().unwrap_or(false);
if ui.checkbox(&mut vis, "").changed() {
if hidden.len() <= el.index {
hidden.resize(el.index + 1, false);
}
hidden[el.index] = !vis;
recompose = true;
}
ui.label(el.index.to_string());
let name = if el.drawn {
egui::RichText::new(&el.name)
} else {
egui::RichText::new(&el.name).weak()
};
if ui
.selectable_label(
*selected_element == Some(el.index),
name,
)
.clicked()
{
*selected_element = Some(el.index);
}
ui.label(
el.parent
.map(|p| p.to_string())
.unwrap_or_else(|| "·".into()),
);
ui.label(format!("{:#x}", el.kind));
ui.label(format!("{},{}", el.pivot.0, el.pivot.1));
ui.label(match el.rest {
// The resting pose is the max-dwell
// keyframe, not the first or the last.
Some((x, y)) => {
format!("({x},{y}) · {} kf", el.keyframes)
}
None => "·".into(),
});
ui.end_row();
}
});
});
if let Some(sel) = *selected_element {
if let Some(el) = result.elements.iter().find(|e| e.index == sel) {
ui.separator();
ui.strong(&el.name);
if let Some(s) = &el.sprite {
ui.label(format!("sprite: {s}"));
}
if let Some(link) = &el.focus_link {
ui.label(format!("focused state: {link}"));
}
}
}
});
});
if let Some((pi, bi)) = pick {
screens.selected = Some(pi);
screens.build = bi;
screens.hidden.clear();
screens.selected_element = None;
recompose = true;
}
if recompose {
if let Some(pi) = screens.selected {
if let Some(pak) = screens.paks.get(pi) {
compose.send(RequestScreenCompose {
pak: pak.path.clone(),
build: screens.build,
focus: screens.show_focus,
animated: screens.show_animated,
hidden: screens.hidden.clone(),
});
}
}
}
screens.open &= open;
}
// ── Save-file inspector (View ▸ Save File) ───────────────────────────────────
/// Colour a field by how well it is actually understood, so the panel cannot be
/// read as "all of this is solved" — eleven GHAD words are not.
#[cfg(not(target_arch = "wasm32"))]
fn confidence_color(c: sylpheed_formats::savegame::Confidence) -> egui::Color32 {
use sylpheed_formats::savegame::Confidence::*;
match c {
Confirmed => egui::Color32::from_rgb(120, 200, 140),
Probable => egui::Color32::from_rgb(224, 196, 110),
Unknown => egui::Color32::GRAY,
Refuted => egui::Color32::from_rgb(224, 110, 110),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn confidence_mark(c: sylpheed_formats::savegame::Confidence) -> &'static str {
use sylpheed_formats::savegame::Confidence::*;
match c {
Confirmed => "",
Probable => "~",
Unknown => "?",
Refuted => "",
}
}
#[cfg(not(target_arch = "wasm32"))]
fn draw_save_ui(
mut contexts: EguiContexts,
mut saves: ResMut<SaveBrowser>,
mut requests: EventReader<RequestSaveOpen>,
mut open_save: EventWriter<RequestSaveOpen>,
) {
if requests.read().next().is_some() {
saves.open = true;
}
if !saves.open {
return;
}
let ctx = contexts.ctx_mut().clone();
let mut open = true;
let mut reopen = false;
egui::Window::new("💾 Save File")
.default_width(720.0)
.default_height(620.0)
.open(&mut open)
.show(&ctx, |ui| {
ui.horizontal(|ui| {
if ui.button("Open savedata…").clicked() {
reopen = true;
}
ui.checkbox(&mut saves.show_unknown, "Show unidentified fields");
if saves.loading {
ui.spinner();
ctx.request_repaint();
}
});
ui.separator();
let Some(r) = saves.result.as_ref() else {
ui.label(
"A save lives in the emulator's content tree, not on the disc:\n\
<content>/<XUID>/535107D4/00000001/game01/savedata",
);
return;
};
if let Some(err) = &r.error {
ui.colored_label(egui::Color32::from_rgb(224, 86, 122), err);
return;
}
ui.label(&r.path);
ui.label(format!(
"GDHA · {} B header + {} B deflate → {} B payload · phase {}",
r.header_len, r.deflate_len, r.payload_len, r.phase
));
// The round-trip is the check the whole layout rests on: the title
// writes its struct field-by-field with no packing, so a correct
// parse must reproduce the payload exactly.
if r.round_trips {
ui.colored_label(
egui::Color32::from_rgb(120, 200, 140),
"✔ re-serializes byte-identically",
);
} else {
ui.colored_label(
egui::Color32::from_rgb(224, 86, 122),
"✖ round-trip MISMATCH — this parse is wrong",
);
}
ui.separator();
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
ui.strong("GHAD progress block");
egui::Grid::new("g_save_ghad").striped(true).num_columns(4).show(ui, |ui| {
for h in ["", "Offset", "Field", "Value"] {
ui.strong(h);
}
ui.end_row();
for f in &r.fields {
use sylpheed_formats::savegame::Confidence;
if !saves.show_unknown && f.confidence == Confidence::Unknown {
continue;
}
let col = confidence_color(f.confidence);
ui.colored_label(col, confidence_mark(f.confidence));
ui.label(format!("+{}", f.offset));
let label = ui.colored_label(col, &f.name);
if !f.note.is_empty() {
label.on_hover_text(&f.note);
}
ui.label(&f.value);
ui.end_row();
}
});
ui.add_space(8.0);
ui.strong("Arsenal development");
{
use sylpheed_formats::savegame::DevelopState;
let owned = r.develop.iter().filter(|(_, d)| *d == DevelopState::Developed).count();
let ready = r.develop.iter().filter(|(_, d)| *d == DevelopState::Developable).count();
ui.label(format!(
"{owned} developed · {ready} developable · {} locked (index space = \
strings.tbl item order, cut items included)",
r.develop.len() - owned - ready
));
ui.horizontal_wrapped(|ui| {
for (i, d) in &r.develop {
let (txt, col) = match d {
DevelopState::Developed => ("4", egui::Color32::from_rgb(120, 200, 140)),
DevelopState::Developable => ("2", egui::Color32::from_rgb(224, 196, 110)),
DevelopState::Locked => ("0", egui::Color32::DARK_GRAY),
DevelopState::Other(_) => ("?", egui::Color32::from_rgb(224, 110, 110)),
};
ui.colored_label(col, txt).on_hover_text(format!("item {i}"));
}
});
}
ui.add_space(8.0);
ui.strong("Per-stage records");
ui.label(
egui::RichText::new(
"SHAB — these are the per-stage results, NOT the UI's save slots.",
)
.weak(),
);
egui::Grid::new("g_save_shab").striped(true).num_columns(4).show(ui, |ui| {
for h in ["Stage", "difficulty ~", "points ?", "best time ✔"] {
ui.strong(h);
}
ui.end_row();
for (stage, a, b, ms) in &r.records {
ui.label(format!("{stage:02}"));
ui.label(a.to_string());
ui.label(b.to_string());
ui.label(sylpheed_formats::savegame::fmt_millis(*ms));
ui.end_row();
}
});
ui.add_space(8.0);
ui.strong("Header summary");
ui.label(
egui::RichText::new(
"What the in-game Details panel actually reads. 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.",
)
.weak(),
);
egui::Grid::new("g_save_summary").striped(true).num_columns(4).show(ui, |ui| {
for h in ["Header", "Field", "Value", "Payload"] {
ui.strong(h);
}
ui.end_row();
for (off, name, value, payload) in &r.summary {
ui.label(format!("{off:#04x}"));
ui.label(name);
ui.label(value.to_string());
match payload {
Some(p) if u64::from(*value) == *p => {
ui.colored_label(egui::Color32::from_rgb(120, 200, 140), "agrees")
}
Some(p) => ui.colored_label(
egui::Color32::from_rgb(224, 110, 110),
format!("STALE — payload has {p}"),
),
None => ui.label("·"),
};
ui.end_row();
}
});
});
});
if reopen {
open_save.send(RequestSaveOpen::default());
}
saves.open &= open;
}