diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index 85cd4b5..d6463ae 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -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, + pub parent: Option, + 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, + 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, + pub elements: Vec, + /// Sprites that named a child the bundle does not hold, or that failed to + /// decode — surfaced rather than silently dropped. + pub missing: Vec, + /// 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, +} + +/// 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, + /// Which pak is selected, and which of its builds. + pub selected: Option, + 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, + pub composed: Option, + /// 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, +} + +/// 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, +} + +// ── 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, + 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, + /// (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)>, +} + +/// State of the save-file inspector. +#[derive(Resource, Default)] +pub struct SaveBrowser { + pub open: bool, + pub loading: bool, + pub result: Option>, + /// 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, +} + /// 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), /// The decoded game-data tables for the browser. GameDataLoaded(Box), + /// The list of UI screen paks found on the disc. + ScreenCatalogLoaded(Vec), + /// 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, + }, + /// A `savedata` file parsed (or the parse error, verbatim). + SaveLoaded(Box), /// User dismissed the file dialog — not an error. Cancelled, Error(String), @@ -988,13 +1135,18 @@ impl Plugin for IsoLoaderPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .add_event::() .add_event::() .add_event::() .add_event::() .add_event::() .add_event::() - .add_event::(); + .add_event::() + .add_event::() + .add_event::() + .add_event::(); #[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, mut game_data: ResMut, mut ships: ResMut, + mut screens: ResMut, + mut saves: ResMut, ) { 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 { 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, + iso_state: Res, + // The listed file set is the browser's, and it covers both source kinds. + browser: Res, + channels: Res, + mut screens: ResMut, +) { + 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 { + 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, + iso_state: Res, + channels: Res, + mut screens: ResMut, +) { + 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> = 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 = 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, + channels: Res, + mut saves: ResMut, +) { + 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::>() + .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"))] diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index e66d389..d1045c2 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -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, + mut requests: EventReader, + mut compose: EventWriter, +) { + 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, + mut requests: EventReader, + mut open_save: EventWriter, +) { + 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\ + //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; +}