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:
@@ -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"))]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user