Files
Sylpheed/crates/sylpheed-viewer/src/ui.rs
MechaCat02 d1685d67c9
Some checks failed
CI / Native — ubuntu-latest (push) Failing after 8m13s
CI / Native — macos-latest (push) Has been cancelled
CI / Native — windows-latest (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / WASM — Web (push) Has been cancelled
viewer: show where a cutscene's voice actually is, and let you hear it
The Cutscenes window printed the voice token as text and offered no way to play
it, which left the most confusing thing on the disc invisible.

The movie voices are one continuous XMA stream chunked into VOICE_*.slb entries
whose boundaries do NOT match the cutscene cues, so the bank named after a movie
need not hold that movie's audio. Measured, on the retail disc:

  ADV     region 433930240..437044592  inside VOICE_ADV.slb        name honest
  S00A    region 452798464..455499120  inside VOICE_S00A.slb       name honest
  RT01A   region 437044592..437345648  inside VOICE_ADV.slb        NAME LIES

RT01A's voice sits in bytes belonging to the entry named after the intro movie.
A viewer that played the name-matched bank would be confidently wrong for
exactly the cutscenes where it matters, and would look right on the two that are
easiest to check.

So the window now shows BOTH locations -- the named bank with its byte range,
and the resolved region -- and states plainly whether the name is honest,
highlighting it when it is not. Play routes through the movie form of
RequestAudio, which resolves the region rather than reading the bank.

Static data only: sound.pak and tables.pak, both on the disc.
2026-08-29 16:21:34 +02:00

2731 lines
114 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! egui-based debug UI for the asset viewer.
//!
//! Provides panels for:
//! - File browser (list ISO / extracted game files, filter by name)
//! - Texture inspector (preview + format info)
//! - File info (size, detected format for non-texture files)
//! - RE notes (track discoveries during reverse engineering)
use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{
AudioLibrary, AudioPreview, CutsceneBrowser, FileInfo, FileSelected, GameCategory, GameData,
ImageRgba, IsoLoaderSystemSet, IsoState, ModelPreview, MovieSubtitles, MovieVoice, PakContent,
PakView, RequestAudio, RequestAudioLibrary, RequestCutscenes, RequestGameData, RequestOpenDir,
RequestOpenIso, RequestSaveOpen, RequestScreenCatalog, RequestScreenCompose, RequestShipCatalog,
RequestShipRender, RequestSubtitles, SaveBrowser, ScreenBrowser, ShipBrowser, SkyboxPreview,
TextPreview, TexturePreview, VideoPreview,
};
use crate::ViewerState;
use sylpheed_formats::SubLang;
pub struct ViewerUiPlugin;
impl Plugin for ViewerUiPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(FileBrowserState::default());
// Run after the iso_loader chain so we see the frame's final state.
app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet));
// The standalone browser windows are native-only (their loaders are),
// and so are their draw systems — so the registration has to be gated
// too, or the wasm build fails on an undefined name (`just ci` runs
// `cargo check --target wasm32-unknown-unknown`).
#[cfg(not(target_arch = "wasm32"))]
{
app.add_systems(Update, draw_game_data_ui.after(IsoLoaderSystemSet));
app.add_systems(Update, draw_ships_ui.after(IsoLoaderSystemSet));
app.add_systems(Update, draw_screens_ui.after(IsoLoaderSystemSet));
app.add_systems(Update, draw_save_ui.after(IsoLoaderSystemSet));
app.add_systems(Update, draw_cutscenes_ui.after(IsoLoaderSystemSet));
}
}
}
/// State for the file browser panel.
#[derive(Resource, Default)]
pub struct FileBrowserState {
pub files: Vec<String>,
pub selected: Option<usize>,
pub filter: String,
/// True from the moment a file is clicked until its content is applied —
/// drives the "Loading…" indicator so the click registers immediately.
pub loading: bool,
}
// ── Directory tree ────────────────────────────────────────────────────────────
/// One directory node in the ephemeral file tree. Rebuilt from the flat path
/// list each frame; leaves carry their original index into `browser.files` so
/// selection stays stable regardless of tree shape or filtering.
#[derive(Default)]
struct TreeDir {
/// Child directories, keyed by name (`BTreeMap` = stable alphabetical order).
dirs: std::collections::BTreeMap<String, TreeDir>,
/// Leaf files: (display name, original index into `browser.files`).
files: Vec<(String, usize)>,
}
/// True for an IPFB data segment (`foo.p00`, `foo.p01`, …). These are the pack's
/// data body, not standalone files — the `.pak` index is what the user opens, so
/// they're hidden from the tree (their content loads when the `.pak` is opened).
fn is_pak_segment(leaf: &str) -> bool {
matches!(leaf.rsplit_once('.'), Some((_, ext))
if ext.len() == 3
&& ext.as_bytes()[0] == b'p'
&& ext.as_bytes()[1].is_ascii_digit()
&& ext.as_bytes()[2].is_ascii_digit())
}
/// Split a full path into (directory components, leaf name).
fn split_for_tree(full: &str) -> (Vec<String>, String) {
let mut comps: Vec<String> = full.split('/').map(str::to_string).collect();
let leaf = comps.pop().unwrap_or_default();
(comps, leaf)
}
/// Build the directory tree, keeping only leaves whose full path contains
/// `filter` (case-insensitive; empty filter keeps everything). `.pNN` data
/// segments are skipped — only their `.pak` index is shown.
fn build_tree(files: &[String], filter: &str) -> TreeDir {
let filter = filter.to_lowercase();
let mut root = TreeDir::default();
for (idx, full) in files.iter().enumerate() {
if !filter.is_empty() && !full.to_lowercase().contains(&filter) {
continue;
}
let (dirs, leaf) = split_for_tree(full);
if is_pak_segment(&leaf) {
continue;
}
let mut node = &mut root;
for comp in dirs {
node = node.dirs.entry(comp).or_default();
}
node.files.push((leaf, idx));
}
root
}
/// Render a directory node recursively: sub-directories (collapsible) first,
/// then leaf files as selectable labels.
#[allow(clippy::too_many_arguments)]
fn render_dir(
ui: &mut egui::Ui,
node: &TreeDir,
force_open: bool,
selected: &mut Option<usize>,
loading: &mut bool,
events: &mut EventWriter<FileSelected>,
files: &[String],
) {
for (name, child) in &node.dirs {
egui::CollapsingHeader::new(format!("📁 {name}"))
.id_salt(name)
.default_open(force_open)
.show(ui, |ui| {
render_dir(ui, child, force_open, selected, loading, events, files);
});
}
for (leaf, idx) in &node.files {
let is_selected = *selected == Some(*idx);
if ui
.add(egui::SelectableLabel::new(is_selected, leaf.as_str()))
.clicked()
{
*selected = Some(*idx);
*loading = true; // show the spinner until the content is applied
events.send(FileSelected(files[*idx].clone()));
}
}
}
/// Bundled event writers for the UI, to keep `draw_viewer_ui` under Bevy's
/// 16-parameter system limit.
#[derive(bevy::ecs::system::SystemParam)]
struct UiEvents<'w> {
open_iso: EventWriter<'w, RequestOpenIso>,
open_dir: EventWriter<'w, RequestOpenDir>,
file_selected: EventWriter<'w, FileSelected>,
subtitles: EventWriter<'w, RequestSubtitles>,
audio: EventWriter<'w, RequestAudio>,
audio_lib: EventWriter<'w, RequestAudioLibrary>,
game_data: EventWriter<'w, RequestGameData>,
ships: EventWriter<'w, RequestShipCatalog>,
screens: EventWriter<'w, RequestScreenCatalog>,
save: EventWriter<'w, RequestSaveOpen>,
cutscenes: EventWriter<'w, RequestCutscenes>,
}
fn draw_viewer_ui(
mut contexts: EguiContexts,
mut viewer: ResMut<ViewerState>,
mut browser: ResMut<FileBrowserState>,
iso_state: Res<IsoState>,
preview: Res<TexturePreview>,
text_preview: Res<TextPreview>,
mut pak_view: ResMut<PakView>,
skybox: Res<SkyboxPreview>,
model: Res<ModelPreview>,
mut video: ResMut<VideoPreview>,
file_info: Res<FileInfo>,
mut subtitles: ResMut<MovieSubtitles>,
mut movie_voice: ResMut<MovieVoice>,
mut audio: ResMut<AudioPreview>,
mut audio_lib: ResMut<AudioLibrary>,
mut events: UiEvents,
) {
let ctx = contexts.ctx_mut();
// ── Menu bar ──────────────────────────────────────────────────────────
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
egui::menu::bar(ui, |ui| {
ui.menu_button("File", |ui| {
#[cfg(not(target_arch = "wasm32"))]
{
if ui.button("Open ISO disc image…").clicked() {
events.open_iso.send_default();
ui.close_menu();
}
if ui.button("Open extracted folder…").clicked() {
events.open_dir.send_default();
ui.close_menu();
}
ui.separator();
if ui.button("Quit").clicked() {
std::process::exit(0);
}
}
#[cfg(target_arch = "wasm32")]
ui.colored_label(
egui::Color32::GRAY,
"File loading not available in browser",
);
});
ui.menu_button("View", |ui| {
if ui.button("🔊 Audio Library…").clicked() {
audio_lib.open = true;
if !audio_lib.loaded && !audio_lib.loading {
audio_lib.loading = true;
events.audio_lib.send_default();
}
ui.close_menu();
}
if ui.button("🗃 Game Data…").clicked() {
// The Game Data window (its own system) reads this event to
// open + lazily decode the tables.
events.game_data.send_default();
ui.close_menu();
}
if ui.button("🚀 Ships…").clicked() {
// The Ships window (its own system) reads this event to open +
// lazily scan the stage containers for assemblable ships.
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("🎬 Cutscenes…").clicked() {
// The manifest binds every cutscene slot to its movie,
// subtitle track, voice token and telop overlay.
events.cutscenes.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| {
if ui.button("Controls…").clicked() {
// TODO: controls popup
}
if ui.button("About").clicked() {
// TODO: about dialog
}
});
});
});
// ── Standalone voice-line browser (floating window) ───────────────────
if audio_lib.open {
let mut open = true;
egui::Window::new("🔊 Audio Library")
.default_width(400.0)
.default_height(520.0)
.open(&mut open)
.show(ctx, |ui| {
if audio_lib.loading {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Reading sounds.tbl…");
});
ctx.request_repaint();
} else if !audio_lib.loaded {
ui.label("Open a game source first.");
} else {
use sylpheed_formats::slb::VoiceLang;
ui.horizontal(|ui| {
ui.label("Voice:");
// The table name IS the selector: there is no language
// field inside sounds.tbl, so switching re-reads the
// other file. Music/jingles/SFX are shared and stay.
for lang in VoiceLang::ALL {
if ui
.selectable_label(audio_lib.lang == lang, lang.label())
.clicked()
&& audio_lib.lang != lang
{
audio_lib.lang = lang;
audio_lib.loaded = false;
audio_lib.entries.clear();
audio_lib.reload = true;
}
}
ui.separator();
ui.label("Filter:");
ui.text_edit_singleline(&mut audio_lib.filter);
});
let f = audio_lib.filter.to_lowercase();
// Group category → speaker. The category comes from the path
// shape, so the root banks (music/jingles/SFX) get real
// headings instead of the "?" a directory split gave them.
use std::collections::BTreeMap;
type Group<'a> = BTreeMap<&'a str, Vec<&'a sylpheed_formats::slb::AudioEntry>>;
let mut groups: BTreeMap<
sylpheed_formats::slb::AudioCategory,
Group<'_>,
> = BTreeMap::new();
for e in &audio_lib.entries {
if !f.is_empty() && !e.clip.name.to_lowercase().contains(&f) {
continue;
}
groups
.entry(e.category)
.or_default()
.entry(e.clip.speaker.as_str())
.or_default()
.push(e);
}
let shown: usize = groups.values().flat_map(|s| s.values()).map(Vec::len).sum();
ui.label(
egui::RichText::new(format!(
"{shown} / {} banks",
audio_lib.entries.len()
))
.weak()
.small(),
);
ui.separator();
let filtering = !f.is_empty();
egui::ScrollArea::vertical().show(ui, |ui| {
for (cat, speakers) in &groups {
let ctotal: usize = speakers.values().map(Vec::len).sum();
egui::CollapsingHeader::new(format!(
"{} ({ctotal})",
cat.label()
))
.id_salt(("acat", *cat))
.default_open(filtering || ctotal <= 40)
.show(ui, |ui| {
for (speaker, entries) in speakers {
// A single-bank group (Static.slb) would be a
// pointless nested header.
let flat = speakers.len() == 1 || entries.len() == 1;
let mut row = |ui: &mut egui::Ui| {
for e in entries {
ui.horizontal(|ui| {
if ui
.button("▶")
.on_hover_text(&e.clip.name)
.clicked()
{
audio.generation =
audio.generation.wrapping_add(1);
audio.loading = true;
audio.active = true;
audio.error = None;
audio.name = e.clip.display.clone();
events.audio.send(RequestAudio {
clip: e.clip.name.clone(),
display: e.clip.display.clone(),
movie: None,
mono: e.category.is_voice(),
generation: audio.generation,
});
}
ui.label(&e.clip.display);
});
}
};
if flat {
row(ui);
} else {
egui::CollapsingHeader::new(format!(
"{speaker} ({})",
entries.len()
))
.id_salt(("aspk", *cat, *speaker))
.default_open(filtering || entries.len() <= 6)
.show(ui, &mut row);
}
}
});
}
});
}
});
audio_lib.open = open;
}
// ── Left panel: file browser ──────────────────────────────────────────
egui::SidePanel::left("file_browser")
.resizable(true)
.default_width(280.0)
.show(ctx, |ui| {
ui.heading("Game Files");
ui.separator();
// Filter input
ui.horizontal(|ui| {
ui.label("Filter:");
ui.text_edit_singleline(&mut browser.filter);
});
ui.separator();
// File list
egui::ScrollArea::vertical().show(ui, |ui| {
if iso_state.loading {
ui.spinner();
ui.label("Loading…");
} else if let Some(err) = &iso_state.error {
ui.colored_label(
egui::Color32::RED,
format!("⚠ Error:\n{}", err),
);
} else if browser.files.is_empty() {
ui.colored_label(
egui::Color32::YELLOW,
"No files loaded.\nUse File → Open ISO disc image\n\
or File → Open extracted folder.",
);
} else {
// Build a directory tree from the flat path list. Cheap to
// rebuild every frame (a few hundred short paths); cloning
// `files` first sidesteps borrowing `browser` immutably and
// mutably (selected) at the same time.
let files = browser.files.clone();
let tree = build_tree(&files, &browser.filter);
let force_open = !browser.filter.is_empty();
let FileBrowserState {
selected, loading, ..
} = &mut *browser;
render_dir(
ui,
&tree,
force_open,
selected,
loading,
&mut events.file_selected,
&files,
);
}
});
});
// ── Bottom panel: status bar ──────────────────────────────────────────
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
// `horizontal_wrapped` so the bar wraps instead of overflowing the
// window on narrow widths; the controls hint is right-aligned and only
// the source's final path component is shown (full path on hover).
ui.horizontal_wrapped(|ui| {
if let Some(label) = &iso_state.source_label {
let short = label.rsplit(['/', '\\']).next().unwrap_or(label);
ui.label(short).on_hover_text(label);
ui.separator();
}
ui.label(format!("{} files", browser.files.len()));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label("LMB orbit · RMB pan · scroll zoom · R reset");
});
});
});
// ── Central area ──────────────────────────────────────────────────────
if audio.active {
// Standalone audio player takes over the central panel.
egui::CentralPanel::default().show(ctx, |ui| {
ctx.request_repaint();
draw_audio_player(ui, &mut audio);
});
} else if browser.selected.is_some() && model.active && !browser.loading {
// 3D model: draw the central panel TRANSPARENT so the real Bevy scene
// (mesh + orbit camera) shows through, with just an info overlay on top.
egui::CentralPanel::default()
.frame(egui::Frame::none())
.show(ctx, |ui| {
egui::Frame::none()
.fill(egui::Color32::from_black_alpha(160))
.inner_margin(egui::Margin::same(8.0))
.rounding(egui::Rounding::same(4.0))
.show(ui, |ui| {
ui.heading(&model.name);
ui.label(&model.info);
ui.colored_label(
egui::Color32::from_gray(160),
"3D preview · LMB orbit · RMB pan · scroll zoom · R reset",
);
ui.colored_label(
egui::Color32::from_gray(140),
"position + normal + UV from the XBG7 vertex declaration",
);
});
});
} else if browser.selected.is_some() {
egui::CentralPanel::default().show(ctx, |ui| {
if browser.loading {
// A file was just clicked — show immediate feedback while the
// background read + decode runs, instead of the previous item.
// Keep repainting so the spinner animates even if the app is in a
// reactive (idle) winit mode while the worker thread churns.
ctx.request_repaint();
let name = browser
.selected
.and_then(|i| browser.files.get(i))
.map(|p| p.rsplit('/').next().unwrap_or(p))
.unwrap_or("");
ui.centered_and_justified(|ui| {
ui.horizontal(|ui| {
ui.spinner();
ui.label(format!("Loading {name}…"));
});
});
} else if video.active {
let mut lang_changed = false;
let mut play_voice_solo = false;
draw_video_player(
ui,
&mut video,
&mut subtitles,
&mut movie_voice,
&mut lang_changed,
&mut play_voice_solo,
);
if play_voice_solo {
if let Some(movie) = movie_voice.movie.clone() {
video.playing = false; // pause the video; audio takes over
audio.generation = audio.generation.wrapping_add(1);
audio.loading = true;
audio.active = true; // show the panel immediately (spinner)
audio.error = None;
audio.name = format!("VOICE_{movie}");
events.audio.send(RequestAudio {
clip: String::new(),
display: format!("VOICE_{movie}"),
movie: Some((movie.clone(), movie_voice.lang)),
mono: true, // a cutscene voice track
generation: audio.generation,
});
}
}
if lang_changed {
if let Some(movie) = subtitles.movie.clone() {
subtitles.generation = subtitles.generation.wrapping_add(1);
subtitles.cues.clear();
subtitles.loading = true;
events.subtitles.send(RequestSubtitles {
movie,
lang: subtitles.lang,
generation: subtitles.generation,
});
}
}
} else if !skybox.faces.is_empty() {
draw_skybox_grid(ui, &skybox);
} else if pak_view.loaded {
draw_pak_browser(ui, &mut pak_view);
} else if let Some(text) = &text_preview.content {
// ── Plain-text viewer ──
ui.horizontal(|ui| {
ui.heading(&file_info.name);
ui.separator();
ui.label(&text_preview.encoding);
ui.separator();
ui.checkbox(&mut viewer.text_wrap, "Wrap");
if text_preview.truncated {
ui.separator();
ui.colored_label(egui::Color32::YELLOW, "(truncated)");
}
});
ui.separator();
egui::ScrollArea::both().show(ui, |ui| {
// Read-only: `&str` is a non-editable TextBuffer, and
// `.interactive(false)` keeps it selectable/copyable.
let mut buf = text.as_str();
let mut edit = egui::TextEdit::multiline(&mut buf)
.font(egui::TextStyle::Monospace)
.code_editor()
.desired_width(f32::INFINITY)
.interactive(false);
if !viewer.text_wrap {
// Don't wrap: let ScrollArea scroll horizontally.
edit = edit.clip_text(false);
}
ui.add(edit);
});
} else if let Some(egui_id) = preview.egui_id {
// Texture preview
ui.heading(&file_info.name);
ui.label(&preview.format_info);
if !preview.format_info.is_empty() && preview.format_info.contains("failed") {
ui.colored_label(egui::Color32::YELLOW, &preview.format_info);
}
ui.separator();
// Scale to fit, preserving aspect ratio, never upscale
let avail = ui.available_size();
let scale = if preview.width == 0 || preview.height == 0 {
1.0_f32
} else {
(avail.x / preview.width as f32)
.min(avail.y / preview.height as f32)
.min(1.0)
};
let display_w = preview.width as f32 * scale;
let display_h = preview.height as f32 * scale;
ui.add(egui::Image::new(egui::load::SizedTexture::new(
egui_id,
[display_w, display_h],
)));
} else {
// Non-texture file info
ui.heading(&file_info.name);
if !preview.format_info.is_empty() {
ui.colored_label(egui::Color32::YELLOW, &preview.format_info);
}
if file_info.size_bytes > 0 {
ui.separator();
ui.label(format!("Size: {} bytes", file_info.size_bytes));
if let Some(fmt) = file_info.detected_format {
ui.label(format!("Detected format: {:?}", fmt));
}
} else if iso_state.loading {
ui.spinner();
ui.label("Loading file…");
}
}
});
} else {
// No file selected — show welcome / RE notes
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Project Sylpheed: Arc of Deception");
ui.label("Asset Viewer · Game Arts / SETA · Square Enix (Xbox 360)");
ui.separator();
ui.collapsing("Getting Started", |ui| {
ui.label("Option A — open an ISO directly:");
ui.code(" File → Open ISO disc image…");
ui.separator();
ui.label("Option B — extract first, then open folder:");
ui.code(" xdvdfs unpack sylpheed.iso ./assets/");
ui.code(" File → Open extracted folder…");
ui.separator();
ui.label(
"Open a .pak to browse its entries, a .xpr for textures, \
an .xbg mesh for the 3D preview, or a movie .wmv to play it.",
);
});
ui.collapsing("Reverse-engineered formats", |ui| {
let done = egui::Color32::LIGHT_GREEN;
let partial = egui::Color32::from_rgb(150, 210, 255);
let todo = egui::Color32::YELLOW;
egui::Grid::new("re_notes").striped(true).num_columns(3).show(ui, |ui| {
ui.strong("Format");
ui.strong("Status");
ui.strong("Notes");
ui.end_row();
let mut row = |ui: &mut egui::Ui, fmt: &str, c, status: &str, notes: &str| {
ui.label(fmt);
ui.colored_label(c, status);
ui.label(notes);
ui.end_row();
};
row(ui, "IPFB .pak/.pNN", done, "✓ done",
"Archive TOC + name-hash recovered (paths resolved)");
row(ui, "IDXD objects", partial, "◑ most",
"Reflective ship/weapon/effect defs; some fields defaulted in-code");
row(ui, "XBG7 mesh", done, "✓ done",
"3D models — position + normal + UV from vertex decl");
row(ui, "XPR2 texture", done, "✓ done",
"A8R8G8B8 / DXT1/3/5, de-tiled; 2D + cubemaps");
row(ui, "T8aD / RATC / LSTA", partial, "◑ most",
"2D UI textures, bundles, sprite lists (colours unverified)");
row(ui, "IXUD subtitles", done, "✓ done",
"Localized movie subtitle cue tables");
row(ui, "Font (OTF/TTF/ttcf)", done, "✓ done",
"Embedded subtitle fonts — metadata + glyph sample");
row(ui, "WMV cutscene", done, "✓ done",
"wmv3 / wmapro playback with transport + subtitles");
row(ui, "XISO disc", done, "✓ done",
"XDVDFS filesystem — direct ISO browsing");
row(ui, "XMA audio", todo, "⏳ wip",
"Xbox 360 XMA → PCM (scaffold)");
});
});
});
}
}
// ── Data pack (IPFB / IDXD) browser ───────────────────────────────────────────
/// Master-detail view for an open `.pak`: entry list on the left, the selected
/// entry's content on the right (IDXD table, subtitle cues, font sample, image,
/// or text — whatever `classify_content` decoded off-thread).
fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) {
ui.horizontal(|ui| {
ui.heading(&pak.name);
ui.separator();
ui.label(format!("{} entries", pak.rows.len()));
});
if let Some(err) = &pak.error {
ui.separator();
ui.colored_label(egui::Color32::RED, format!("⚠ {err}"));
return;
}
ui.separator();
// Destructure so the columns can read `rows` while mutating the selection +
// the egui-side caches — without cloning the (now heavy) rows each frame.
let PakView {
rows,
selected,
img_tex,
..
} = pak;
let mut new_selection = *selected;
ui.columns(2, |cols| {
// Left — entry list.
egui::ScrollArea::vertical()
.id_salt("pak_list")
.show(&mut cols[0], |ui| {
for (i, row) in rows.iter().enumerate() {
// Prefer the recovered TOC path (name-hash) over the generic
// kind hint; colour resolved names green so they stand out.
let label = row.name.clone().unwrap_or_else(|| row_kind(row));
let mut text = egui::RichText::new(format!(
"{:08x} {:<7} {}",
row.hash, row.format, label
))
.monospace();
if row.name.is_some() {
text = text.color(egui::Color32::LIGHT_GREEN);
}
if ui
.add(egui::SelectableLabel::new(*selected == Some(i), text))
.clicked()
{
new_selection = Some(i);
}
}
});
// Right — detail of the selected entry, dispatched on decoded content.
egui::ScrollArea::vertical()
.id_salt("pak_detail")
.show(&mut cols[1], |ui| match selected.and_then(|i| rows.get(i)) {
None => {
ui.label("Select an entry to inspect.");
}
Some(row) => match &row.content {
PakContent::Subtitle(sub) => draw_subtitle_detail(ui, row, sub),
PakContent::Font { info, sample } => {
draw_font_detail(ui, row, info, sample.as_ref(), img_tex)
}
PakContent::Png(img) => draw_png_detail(ui, row, img, img_tex),
PakContent::T8ad(img) => draw_t8ad_detail(ui, row, img, img_tex),
PakContent::Lsta(frames) => draw_lsta_detail(ui, row, frames, img_tex),
PakContent::Ratc(children, ui_screen) => {
draw_ratc_detail(ui, row, children, ui_screen.as_ref(), img_tex)
}
PakContent::Text { text, encoding } => {
draw_text_detail(ui, row, text, encoding)
}
PakContent::Audio(info) => draw_audio_detail(ui, row, info),
PakContent::None => draw_idxd_detail(ui, row),
},
});
});
*selected = new_selection;
}
/// Short kind hint for the entry list (subtitle/font/image/text), falling back
/// to the IDXD identity.
fn row_kind(row: &crate::iso_loader::PakRow) -> String {
match &row.content {
PakContent::Subtitle(s) => format!("subtitle · {} cues", s.cues.len()),
PakContent::Font { info, .. } => {
if info.family.is_empty() {
"font".into()
} else {
info.family.clone()
}
}
PakContent::Png(img) => format!("PNG {}×{}", img.width, img.height),
PakContent::T8ad(img) => format!("T8aD {}×{}", img.width, img.height),
PakContent::Lsta(f) => format!("LSTA · {} sprite(s)", f.len()),
PakContent::Ratc(c, screen) => {
let s = if screen.is_some() { " · UI screen" } else { "" };
format!("RATC · {} item(s){s}", c.len())
}
PakContent::Text { .. } => "text".into(),
PakContent::Audio(a) => match a.xma_packets {
Some(p) => format!("audio · {} · {p} pkts", a.codec.label()),
None => format!("audio · {}", a.codec.label()),
},
PakContent::None => row.identity.clone(),
}
}
/// `mm:ss.SS` timestamp for subtitle cues.
fn fmt_ts(secs: f32) -> String {
let s = secs.max(0.0);
format!("{:02}:{:05.2}", (s / 60.0) as u32, s % 60.0)
}
/// IDXD object detail (or bare format/size for other un-presentable entries).
fn draw_idxd_detail(ui: &mut egui::Ui, row: &crate::iso_loader::PakRow) {
match &row.detail {
Some(d) => {
let title = if row.identity.is_empty() {
row.format.as_str()
} else {
row.identity.as_str()
};
ui.heading(title);
// Recovered original path (name-hash) — the entry's real TOC key.
if let Some(name) = &row.name {
ui.colored_label(egui::Color32::LIGHT_GREEN, format!("📄 {name}"));
}
ui.label(format!("schema 0x{:08x} count {}", d.schema_hash, d.count));
ui.label(format!("{} bytes hash {:08x}", row.size, row.hash));
ui.separator();
if d.fields.is_empty() {
ui.label("(no explicit-value fields)");
} else {
egui::Grid::new("pak_fields")
.striped(true)
.num_columns(2)
.show(ui, |ui| {
for (k, v) in &d.fields {
ui.label(k);
ui.monospace(v);
ui.end_row();
}
});
}
}
None => {
ui.heading(&row.format);
ui.label(format!("{} bytes hash {:08x}", row.size, row.hash));
ui.separator();
ui.label("No decoded preview for this format yet.");
}
}
}
/// IXUD subtitle track: a timecode + text cue table.
fn draw_subtitle_detail(
ui: &mut egui::Ui,
row: &crate::iso_loader::PakRow,
sub: &sylpheed_formats::ixud::Subtitle,
) {
ui.heading("Subtitle track");
let note = if sub.is_reference_only() {
" · reference-only (MSG_* keys)"
} else {
""
};
ui.label(format!(
"IXUD · {} cues · hash {:08x}{note}",
sub.cues.len(),
row.hash
));
ui.separator();
if sub.cues.is_empty() {
ui.label("(empty — no cues)");
return;
}
egui::Grid::new("subtitle_cues")
.striped(true)
.num_columns(2)
.show(ui, |ui| {
ui.strong("Time");
ui.strong("Text");
ui.end_row();
for c in &sub.cues {
let t = match c.end {
Some(e) => format!("{}–{}", fmt_ts(c.start), fmt_ts(e)),
None => fmt_ts(c.start),
};
ui.monospace(t);
ui.label(&c.text);
ui.end_row();
}
});
}
/// Per-entry egui texture cache: `(entry hash, one texture per shown image)`.
type ImgCache = Option<(u32, Vec<egui::TextureHandle>)>;
/// Ensure `cache` holds egui textures for `imgs` under `hash`, rebuilding when
/// the selected entry changes. All image content comes from off-thread decodes;
/// this never touches egui's global font system.
fn ensure_textures(ui: &egui::Ui, hash: u32, imgs: &[&ImageRgba], cache: &mut ImgCache) {
if cache.as_ref().map(|(h, _)| *h) != Some(hash) {
let texs = imgs
.iter()
.enumerate()
.map(|(i, img)| {
let color = egui::ColorImage::from_rgba_unmultiplied(
[img.width as usize, img.height as usize],
&img.rgba,
);
ui.ctx().load_texture(
format!("pak_img_{hash:08x}_{i}"),
color,
egui::TextureOptions::LINEAR,
)
})
.collect();
*cache = Some((hash, texs));
}
}
/// Cache + draw a single `ImageRgba`, scaled to fit the available width (capped
/// by `max_scale`). Shared by the PNG / T8aD / font-sample views.
fn show_cached_image(
ui: &mut egui::Ui,
hash: u32,
img: &ImageRgba,
cache: &mut ImgCache,
max_scale: f32,
) {
ensure_textures(ui, hash, &[img], cache);
if let Some(tex) = cache.as_ref().and_then(|(_, t)| t.first()) {
let scale = (ui.available_width() / img.width.max(1) as f32).min(max_scale);
let (dw, dh) = (img.width as f32 * scale, img.height as f32 * scale);
ui.add(egui::Image::new(egui::load::SizedTexture::new(
tex.id(),
[dw, dh],
)));
}
}
/// Embedded font: metadata + a pre-rasterized sample line (rendered off-thread
/// with `ab_glyph`, shown as an image — no global egui font install).
fn draw_font_detail(
ui: &mut egui::Ui,
row: &crate::iso_loader::PakRow,
info: &sylpheed_formats::FontInfo,
sample: Option<&ImageRgba>,
img_tex: &mut ImgCache,
) {
ui.heading(if info.family.is_empty() {
"Font"
} else {
&info.family
});
ui.label(format!(
"{} · {} face(s) · {} glyphs · hash {:08x}",
info.kind, info.faces, info.glyphs, row.hash
));
ui.separator();
match sample {
Some(img) => {
ui.label("Sample:");
show_cached_image(ui, row.hash, img, img_tex, 1.0);
}
None => {
ui.label("(no sample — font collection or unsupported outlines)");
}
}
}
/// Decoded PNG image, shown via a cached egui texture.
fn draw_png_detail(
ui: &mut egui::Ui,
row: &crate::iso_loader::PakRow,
img: &ImageRgba,
img_tex: &mut ImgCache,
) {
ui.heading(format!("PNG image {}×{}", img.width, img.height));
ui.label(format!("hash {:08x}", row.hash));
ui.separator();
show_cached_image(ui, row.hash, img, img_tex, 2.0);
}
/// A decoded T8aD 2D texture. Colours are not yet verified against the game.
fn draw_t8ad_detail(
ui: &mut egui::Ui,
row: &crate::iso_loader::PakRow,
img: &ImageRgba,
img_tex: &mut ImgCache,
) {
ui.heading(format!("T8aD 2D texture {}×{}", img.width, img.height));
ui.horizontal(|ui| {
ui.label(format!("hash {:08x}", row.hash));
ui.separator();
ui.weak("colours unverified");
});
ui.separator();
show_cached_image(ui, row.hash, img, img_tex, 2.0);
}
/// An LSTA sprite list — the inline T8aD frames in a grid.
fn draw_lsta_detail(
ui: &mut egui::Ui,
row: &crate::iso_loader::PakRow,
frames: &[ImageRgba],
img_tex: &mut ImgCache,
) {
ui.horizontal(|ui| {
ui.heading("LSTA sprite list");
ui.separator();
ui.label(format!("{} sprite(s)", frames.len()));
ui.separator();
ui.weak("colours unverified");
});
ui.separator();
let refs: Vec<&ImageRgba> = frames.iter().collect();
ensure_textures(ui, row.hash, &refs, img_tex);
const CELL: f32 = 150.0;
egui::Grid::new("lsta_sprites")
.num_columns(3)
.spacing([10.0, 10.0])
.show(ui, |ui| {
for (i, frame) in frames.iter().enumerate() {
if let Some(tex) = img_tex.as_ref().and_then(|(_, t)| t.get(i)) {
let aspect = frame.height as f32 / frame.width.max(1) as f32;
ui.add(egui::Image::new(egui::load::SizedTexture::new(
tex.id(),
[CELL, CELL * aspect],
)));
}
if (i + 1) % 3 == 0 {
ui.end_row();
}
}
});
}
/// A RATC bundle — a list of its named children, with thumbnails for the
/// decoded T8aD ones.
fn draw_ratc_detail(
ui: &mut egui::Ui,
row: &crate::iso_loader::PakRow,
children: &[crate::iso_loader::RatcEntry],
ui_screen: Option<&ImageRgba>,
img_tex: &mut ImgCache,
) {
ui.horizontal(|ui| {
ui.heading("RATC bundle");
ui.separator();
ui.label(format!("{} item(s)", children.len()));
if ui_screen.is_some() {
ui.separator();
ui.strong("🖼 UI screen");
}
ui.separator();
ui.weak("colours unverified");
});
ui.separator();
// Cache the reassembled screen (if any) as texture 0, then child thumbnails.
let mut refs: Vec<&ImageRgba> = Vec::new();
if let Some(s) = ui_screen {
refs.push(s);
}
let child_base = refs.len();
refs.extend(children.iter().filter_map(|c| c.image.as_ref()));
ensure_textures(ui, row.hash, &refs, img_tex);
// The reassembled screen, scaled to fit the panel width.
if ui_screen.is_some() {
if let Some(tex) = img_tex.as_ref().and_then(|(_, t)| t.first()) {
ui.label("Reassembled from this screen's .rat layout records:");
let sz = tex.size_vec2();
let scale = (ui.available_width() / sz.x.max(1.0)).min(1.0);
ui.add(egui::Image::new(egui::load::SizedTexture::new(
tex.id(),
[sz.x * scale, sz.y * scale],
)));
ui.weak("Frame/glow decorations (no .rat) and the live 3D background are omitted.");
ui.separator();
}
}
let mut img_i = child_base;
for c in children {
ui.horizontal(|ui| {
if c.image.is_some() {
if let Some(tex) = img_tex.as_ref().and_then(|(_, t)| t.get(img_i)) {
let sz = tex.size_vec2();
let scale = (48.0 / sz.y.max(1.0)).min(1.0);
ui.add(egui::Image::new(egui::load::SizedTexture::new(
tex.id(),
[sz.x * scale, sz.y * scale],
)));
}
img_i += 1;
}
ui.vertical(|ui| {
let name = if c.name.is_empty() { "(unnamed)" } else { &c.name };
ui.strong(name);
ui.weak(format!("{} · {} bytes", c.kind, c.size));
});
});
ui.separator();
}
}
/// Plain-text / XML entry in a read-only monospace box.
fn draw_text_detail(
ui: &mut egui::Ui,
row: &crate::iso_loader::PakRow,
text: &str,
encoding: &str,
) {
ui.horizontal(|ui| {
ui.heading(&row.format);
ui.separator();
ui.label(encoding);
ui.separator();
ui.label(format!("{} bytes", row.size));
});
ui.separator();
let mut buf = text;
ui.add(
egui::TextEdit::multiline(&mut buf)
.font(egui::TextStyle::Monospace)
.code_editor()
.desired_width(f32::INFINITY)
.interactive(false),
);
}
/// An audio stream entry: codec + whatever metadata is derivable. The game's
/// `sound.pak` streams are raw XMA2 (no embedded channels/rate), so most fields
/// read "—" until the sound-bank descriptor + an XMA2 decoder are added.
fn draw_audio_detail(
ui: &mut egui::Ui,
row: &crate::iso_loader::PakRow,
info: &sylpheed_formats::AudioInfo,
) {
ui.horizontal(|ui| {
ui.heading("Audio stream");
ui.separator();
ui.label(info.codec.label());
ui.separator();
ui.label(format!("hash {:08x}", row.hash));
});
ui.separator();
let dash = "—".to_string();
egui::Grid::new("audio_meta").striped(true).num_columns(2).show(ui, |ui| {
ui.strong("Codec");
ui.label(info.codec.label());
ui.end_row();
ui.strong("Channels");
ui.label(info.channels.map(|c| c.to_string()).unwrap_or_else(|| dash.clone()));
ui.end_row();
ui.strong("Sample rate");
ui.label(info.sample_rate.map(|r| format!("{r} Hz")).unwrap_or_else(|| dash.clone()));
ui.end_row();
if let Some(d) = info.duration_secs {
ui.strong("Duration");
ui.label(format!("{d:.2} s"));
ui.end_row();
}
if let Some(p) = info.xma_packets {
ui.strong("XMA packets");
ui.label(format!("{p} (2048 B each)"));
ui.end_row();
}
ui.strong("Size");
ui.label(format!("{} bytes", info.size_bytes));
ui.end_row();
});
if info.codec.needs_decoder() {
ui.separator();
ui.colored_label(
egui::Color32::from_gray(150),
"Raw XMA2 stream — playback needs an XMA2 decoder and the (un-RE'd) \
sound-bank descriptor for per-stream channels/sample-rate.",
);
}
}
// ── Video player (WMV cutscenes) ──────────────────────────────────────────────
/// Format seconds as `mm:ss`.
fn fmt_time(secs: f32) -> String {
let s = secs.max(0.0) as u32;
format!("{:02}:{:02}", s / 60, s % 60)
}
/// Central-panel video player: the current frame plus a transport bar
/// (play/pause, seekable timeline, volume). Also supports click-to-toggle and
/// keyboard shortcuts (space = play/pause, ←/→ = skip 10 s). Playback state
/// lives in `VideoPreview`; the decode/audio engine reacts to it in
/// `advance_video_playback`.
fn draw_video_player(
ui: &mut egui::Ui,
video: &mut VideoPreview,
subs: &mut MovieSubtitles,
voice: &mut MovieVoice,
lang_changed: &mut bool,
play_voice_solo: &mut bool,
) {
// ── Keyboard shortcuts (consumed so focused widgets don't also act). ──
let (mut toggle_play, mut skip) = (false, 0.0_f32);
ui.input_mut(|i| {
if i.consume_key(egui::Modifiers::NONE, egui::Key::Space) {
toggle_play = true;
}
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight) {
skip += 10.0;
}
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft) {
skip -= 10.0;
}
});
if toggle_play {
video.playing = !video.playing;
}
if skip != 0.0 {
let t = (video.position + skip).clamp(0.0, video.duration);
video.position = t;
video.scrub_request = Some(t); // instant preview via the grabber…
video.seek_request = Some(t); // …then the stream restarts here
}
ui.horizontal(|ui| {
ui.heading(&video.name);
ui.separator();
ui.label(format!("{}×{} · wmv3 / wmapro", video.width, video.height));
if !video.has_audio {
ui.separator();
ui.colored_label(egui::Color32::YELLOW, "no audio");
}
// Subtitle + voice controls (right-aligned): language, CC, and Voice.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let before = subs.lang;
egui::ComboBox::from_id_source("subtitle_lang")
.selected_text(subs.lang.label())
.show_ui(ui, |ui| {
for lang in SubLang::ALL {
ui.selectable_value(&mut subs.lang, lang, lang.label());
}
});
if subs.lang != before {
*lang_changed = true;
}
ui.toggle_value(&mut subs.enabled, "CC")
.on_hover_text("Show subtitles");
if subs.loading {
ui.spinner();
} else if subs.enabled && subs.cues.is_empty() {
ui.weak("(no subtitles)");
}
ui.separator();
// Voice-over track: the movie's own stream carries only music+SFX, so
// this layers in the localized cutscene voice.
ui.toggle_value(&mut voice.enabled, "🗣 Voice")
.on_hover_text("Play the cutscene voice track over the movie");
if voice.loading {
ui.spinner();
} else if !voice.available {
ui.weak("(no voice)");
}
if voice.available
&& ui
.button("🎧")
.on_hover_text("Listen to the voice track on its own")
.clicked()
{
*play_voice_solo = true;
}
});
});
ui.separator();
// Reserve room for the transport row so the frame never pushes it off-screen.
const CONTROLS_H: f32 = 34.0;
let avail = ui.available_size();
let frame_h = (avail.y - CONTROLS_H).max(0.0);
let (w, h) = (video.width.max(1) as f32, video.height.max(1) as f32);
let scale = (avail.x / w).min(frame_h / h);
let (disp_w, disp_h) = (w * scale, h * scale);
let mut frame_rect = None;
if let Some(egui_id) = video.egui_id {
ui.allocate_ui(egui::vec2(avail.x, frame_h), |ui| {
ui.vertical_centered(|ui| {
// Click the frame to toggle play/pause.
let resp = ui.add(
egui::Image::new(egui::load::SizedTexture::new(egui_id, [disp_w, disp_h]))
.sense(egui::Sense::click()),
);
if resp.clicked() {
video.playing = !video.playing;
}
frame_rect = Some(resp.rect);
});
});
}
// Caption overlay: every cue active at the current position, stacked over
// the lower third of the frame (overlapping spans show together, newest —
// last in start order — lowest).
if let Some(rect) = frame_rect {
let active = subs.active_cues(video.position);
if !active.is_empty() {
let joined = active
.iter()
.map(|c| c.text.as_str())
.collect::<Vec<_>>()
.join("\n");
paint_caption(ui, rect, &joined);
}
}
// ── Transport bar ──
ui.horizontal(|ui| {
if ui.button(if video.playing { "⏸" } else { "▶" }).clicked() {
video.playing = !video.playing;
}
ui.label(fmt_time(video.position));
// Timeline fills the space left after reserving room for the total-time
// label and the volume control on the right. Dragging scrubs live (the
// grabber shows the frame under the knob); release commits the seek.
let reserved = 170.0;
let tl_width = (ui.available_width() - reserved).max(80.0);
ui.spacing_mut().slider_width = tl_width;
let mut pos = video.position;
let resp =
ui.add(egui::Slider::new(&mut pos, 0.0..=video.duration.max(0.1)).show_value(false));
if resp.changed() {
video.position = pos; // knob + label follow immediately
video.scrub_request = Some(pos);
if resp.dragged() {
video.scrubbing = true;
} else {
// A click on the track (no drag) → commit right away.
video.seek_request = Some(pos);
video.scrubbing = false;
}
}
if resp.drag_stopped() {
video.seek_request = Some(pos);
video.scrubbing = false;
}
ui.label(fmt_time(video.duration));
ui.separator();
ui.label("🔊");
ui.spacing_mut().slider_width = 90.0;
ui.add_enabled(
video.has_audio,
egui::Slider::new(&mut video.volume, 0.0..=1.0).show_value(false),
);
});
}
/// Draw a subtitle caption centered along the bottom of `rect`, with a
/// semi-transparent backing box so it stays legible over any frame.
fn paint_caption(ui: &egui::Ui, rect: egui::Rect, text: &str) {
let painter = ui.painter_at(rect);
// Font scales with the frame; clamped so it's readable but not huge.
let size = (rect.height() * 0.045).clamp(13.0, 30.0);
let font = egui::FontId::proportional(size);
let wrap = rect.width() * 0.9;
let galley = painter.layout(
text.to_string(),
font,
egui::Color32::WHITE,
wrap,
);
let margin = egui::vec2(10.0, 6.0);
let box_size = galley.size() + margin * 2.0;
let top_left = egui::pos2(
rect.center().x - box_size.x / 2.0,
rect.bottom() - box_size.y - rect.height() * 0.04,
);
let bg = egui::Rect::from_min_size(top_left, box_size);
painter.rect_filled(bg, 4.0, egui::Color32::from_black_alpha(160));
painter.galley(top_left + margin, galley, egui::Color32::WHITE);
}
/// Standalone audio player: a transport bar for a decoded voice/sound track with
/// no video. Playback state lives in `AudioPreview`; `advance_audio_playback`
/// reacts to it.
fn draw_audio_player(ui: &mut egui::Ui, audio: &mut AudioPreview) {
ui.horizontal(|ui| {
ui.heading(&audio.name);
ui.separator();
if audio.loading {
ui.spinner();
ui.label("decoding…");
} else if let Some(err) = &audio.error {
ui.colored_label(egui::Color32::from_rgb(224, 86, 122), format!("⚠ {err}"));
} else {
ui.label("sound bank");
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("✖ Close").clicked() {
audio.active = false;
}
});
});
ui.separator();
ui.add_space(ui.available_height() * 0.4);
// Big centered play/pause + a speaker glyph, since there's nothing to show.
ui.vertical_centered(|ui| {
ui.label(egui::RichText::new("🔊").size(64.0));
});
ui.add_space(12.0);
// Transport bar.
ui.horizontal(|ui| {
if ui
.button(if audio.playing { "⏸" } else { "▶" })
.clicked()
{
audio.playing = !audio.playing;
}
ui.label(fmt_time(audio.position));
let tl_width = (ui.available_width() - 170.0).max(80.0);
ui.spacing_mut().slider_width = tl_width;
let mut pos = audio.position;
let resp = ui.add(
egui::Slider::new(&mut pos, 0.0..=audio.duration.max(0.1)).show_value(false),
);
if resp.changed() {
audio.position = pos;
audio.seek_request = Some(pos);
}
ui.label(fmt_time(audio.duration));
ui.separator();
ui.label("🔊");
ui.spacing_mut().slider_width = 90.0;
ui.add(egui::Slider::new(&mut audio.volume, 0.0..=1.0).show_value(false));
});
}
// ── World cubemap (skybox) viewer ─────────────────────────────────────────────
/// Show a world cubemap's 6 faces in a labelled grid (D3D9 order). A true
/// interactive skybox is a follow-up — the face data + order are validated, but
/// the wgpu cube-sampling handedness needs visual confirmation first.
fn draw_skybox_grid(ui: &mut egui::Ui, skybox: &SkyboxPreview) {
ui.horizontal(|ui| {
ui.heading("World cubemap");
ui.separator();
ui.label(&skybox.info);
});
ui.label("6 cube faces, D3D9 order (+X −X +Y −Y +Z −Z).");
ui.separator();
egui::ScrollArea::both().show(ui, |ui| {
const CELL: f32 = 240.0;
egui::Grid::new("cube_faces")
.num_columns(3)
.spacing([10.0, 10.0])
.show(ui, |ui| {
for (i, face) in skybox.faces.iter().enumerate() {
ui.vertical(|ui| {
ui.strong(face.label);
let aspect = face.height as f32 / face.width.max(1) as f32;
ui.add(egui::Image::new(egui::load::SizedTexture::new(
face.egui_id,
[CELL, CELL * aspect],
)));
});
if (i + 1) % 3 == 0 {
ui.end_row();
}
}
});
});
}
// ── Game Data browser ──────────────────────────────────────────────────────────
fn fnum(v: Option<f32>) -> String {
match v {
Some(x) if x == x.trunc() => format!("{}", x as i64),
Some(x) => format!("{x}"),
None => "·".into(),
}
}
fn fint(v: Option<i64>) -> String {
v.map(|x| x.to_string()).unwrap_or_else(|| "·".into())
}
/// The Game Data browser — a floating window over the decoded IDXD tables
/// (weapons, craft, ships, cast, missions, arsenal, flights). Its own system so
/// `draw_viewer_ui` stays under Bevy's system-parameter limit; a `RequestGameData`
/// event (from the View menu) opens it and lazily kicks off decoding.
#[cfg(not(target_arch = "wasm32"))]
fn draw_game_data_ui(
mut contexts: EguiContexts,
mut game_data: ResMut<GameData>,
mut requests: EventReader<RequestGameData>,
) {
if requests.read().next().is_some() {
game_data.open = true;
if !game_data.loaded {
game_data.loading = true;
}
}
if !game_data.open {
return;
}
let ctx = contexts.ctx_mut().clone();
let mut open = true;
egui::Window::new("🗃 Game Data")
.default_width(700.0)
.default_height(560.0)
.open(&mut open)
.show(&ctx, |ui| {
if game_data.loading {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Decoding game tables…");
});
ctx.request_repaint();
return;
}
if !game_data.loaded {
ui.label("Open a game source first (File ▸ Open…).");
return;
}
let GameData { category, filter, snapshot, .. } = &mut *game_data;
ui.horizontal_wrapped(|ui| {
for cat in GameCategory::ALL {
ui.selectable_value(category, cat, cat.label());
}
});
let filterable = !matches!(category, GameCategory::Arsenal | GameCategory::Flights);
if filterable {
ui.horizontal(|ui| {
ui.label("🔍");
ui.text_edit_singleline(filter);
if !filter.is_empty() && ui.small_button("✖").clicked() {
filter.clear();
}
});
}
ui.separator();
let f = filter.to_lowercase();
let hit = |s: &str| f.is_empty() || s.to_lowercase().contains(&f);
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match *category {
GameCategory::Weapons => {
egui::Grid::new("g_weap").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Weapon", "Targets", "Power", "Velocity", "Range", "Reload"] {
ui.strong(h);
}
ui.end_row();
for w in &snapshot.weapons {
let name = w.id.as_deref().unwrap_or("?").trim_start_matches("Weapon_");
if !hit(name) {
continue;
}
ui.label(name);
ui.label(w.target_type.as_deref().unwrap_or("·"));
ui.label(fnum(w.power));
ui.label(fnum(w.velocity));
ui.label(fnum(w.max_range));
ui.label(fint(w.loading_count));
ui.end_row();
}
});
}
GameCategory::Craft => {
egui::Grid::new("g_craft").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Craft", "HP", "Cruise", "Accel", "Radar", "Turrets"] {
ui.strong(h);
}
ui.end_row();
for u in &snapshot.craft {
let name = u.id.as_deref().unwrap_or("?").trim_start_matches("UN_");
if !hit(name) {
continue;
}
ui.label(name);
ui.label(fnum(u.hp));
ui.label(fnum(u.cruising_velocity));
ui.label(fnum(u.acceleration));
ui.label(fnum(u.radar_range));
ui.label(fint(u.turret_count));
ui.end_row();
}
});
}
GameCategory::Vessels => {
egui::Grid::new("g_ves").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Vessel", "HP", "Length", "Turrets", "Bridges", "Shield gen"] {
ui.strong(h);
}
ui.end_row();
for v in &snapshot.vessels {
let name = v.id.as_deref().unwrap_or("?").trim_start_matches("UN_");
if !hit(name) {
continue;
}
ui.label(name);
ui.label(fnum(v.hp));
ui.label(fnum(v.size_z));
ui.label(fint(v.turret_count));
ui.label(fint(v.bridge_count));
ui.label(fint(v.shield_generator_count));
ui.end_row();
}
});
}
GameCategory::Characters => {
egui::Grid::new("g_char").striped(true).num_columns(3).show(ui, |ui| {
for h in ["Name", "Faction", "Portraits"] {
ui.strong(h);
}
ui.end_row();
for c in &snapshot.characters {
if !hit(&c.name) && !hit(&c.faction) {
continue;
}
ui.label(&c.name);
let col = if c.faction == "TCAF" {
egui::Color32::from_rgb(90, 160, 232)
} else if c.faction == "ADAN" {
egui::Color32::from_rgb(224, 86, 122)
} else {
egui::Color32::GRAY
};
ui.colored_label(col, if c.faction.is_empty() { "—" } else { &c.faction });
ui.label(c.faces.to_string());
ui.end_row();
}
});
}
GameCategory::Missions => {
for m in &snapshot.missions {
if !hit(&m.id) && !hit(&m.location) && !m.objectives.iter().any(|o| hit(o)) {
continue;
}
let head = format!("{} · {} · {} phases", m.id, m.location, m.phases);
egui::CollapsingHeader::new(head).id_salt(&m.id).show(ui, |ui| {
if !m.objectives.is_empty() {
ui.strong("Objectives");
for o in &m.objectives {
ui.label(format!("▸ {o}"));
}
}
if !m.lose.is_empty() {
ui.add_space(4.0);
ui.strong("Fail conditions");
for l in &m.lose {
ui.colored_label(egui::Color32::from_rgb(224, 86, 122), l);
}
}
if !m.enemies.is_empty() {
ui.add_space(4.0);
ui.strong("Enemy roster");
for e in &m.enemies {
ui.label(format!("• {e}"));
}
}
});
}
}
GameCategory::Arsenal => {
ui.columns(4, |cols| {
for (i, (title, list)) in [
("Nose", &snapshot.arsenal.nose),
("Arm 1", &snapshot.arsenal.arm1),
("Arm 2", &snapshot.arsenal.arm2),
("Arm 3", &snapshot.arsenal.arm3),
]
.into_iter()
.enumerate()
{
cols[i].strong(format!("{title} ({})", list.len()));
for w in list {
cols[i].label(w.replace('_', " "));
}
}
});
}
GameCategory::Flights => {
ui.label(
egui::RichText::new("Distinct wingman line-ups (story order)").weak().small(),
);
ui.add_space(4.0);
for (n, lineup) in snapshot.flights.iter().enumerate() {
egui::CollapsingHeader::new(format!("Line-up {}", n + 1))
.id_salt(n)
.default_open(n == 0)
.show(ui, |ui| {
egui::Grid::new(("g_flight", n)).striped(true).num_columns(2).show(ui, |ui| {
for (cs, pilot) in lineup {
ui.label(cs);
ui.strong(pilot);
ui.end_row();
}
});
});
}
}
});
});
game_data.open &= open;
}
// ── Ships browser ───────────────────────────────────────────────────────────────
/// The Ships browser — a floating window over the capital ships reconstructed
/// from XBG7 part families. Picking a ship assembles its parts and renders the
/// whole model in the 3D view. Its own system (keeps `draw_viewer_ui` under the
/// parameter limit); a `RequestShipCatalog` event (View menu) opens it and lazily
/// kicks off the stage scan, while clicking a row fires `RequestShipRender`.
#[cfg(not(target_arch = "wasm32"))]
fn draw_ships_ui(
mut contexts: EguiContexts,
mut ships: ResMut<ShipBrowser>,
mut requests: EventReader<RequestShipCatalog>,
mut render: EventWriter<RequestShipRender>,
) {
if requests.read().next().is_some() {
ships.open = true;
}
if !ships.open {
return;
}
let ctx = contexts.ctx_mut().clone();
let mut open = true;
egui::Window::new("🚀 Ships")
.default_width(560.0)
.default_height(560.0)
.open(&mut open)
.show(&ctx, |ui| {
if ships.loading {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Scanning stage models for ships…");
});
ctx.request_repaint();
return;
}
if !ships.loaded {
ui.label("Open a game source first (File ▸ Open…).");
return;
}
// Faction filter chips.
ui.horizontal(|ui| {
for (label, val) in
[("All", ""), ("ADAN", "ADAN"), ("TCAF", "TCAF"), ("Neutral", "Neutral")]
{
let sel = ships.faction == val;
if ui.selectable_label(sel, label).clicked() {
ships.faction = val.to_string();
}
}
});
ui.horizontal(|ui| {
ui.label("🔍");
ui.text_edit_singleline(&mut ships.filter);
if !ships.filter.is_empty() && ui.small_button("✖").clicked() {
ships.filter.clear();
}
});
ui.horizontal(|ui| {
ui.checkbox(&mut ships.show_external, "External parts (bridge/engines/turrets)");
ui.label(
egui::RichText::new("(bridge / shield gens / engines — approximate placement)")
.weak()
.small(),
);
});
ui.separator();
let ShipBrowser { rows, filter, faction, selected, show_external, .. } = &mut *ships;
let show_external = *show_external;
let needle = filter.to_lowercase();
let mut to_render: Option<(String, String, String)> = None;
egui::ScrollArea::vertical().show(ui, |ui| {
let mut last_section = String::new();
for row in rows.iter() {
if !faction.is_empty() && &row.faction != faction {
continue;
}
if !needle.is_empty()
&& !row.name.to_lowercase().contains(&needle)
&& !row.id.contains(&needle)
{
continue;
}
// Section header: capital ships first, then other assemblies.
let section = if row.has_vessel { "Capital ships" } else { "Other assemblies" };
if section != last_section {
ui.add_space(4.0);
ui.label(egui::RichText::new(section).strong().weak());
last_section = section.to_string();
}
let is_sel = selected.as_deref() == Some(row.id.as_str());
let color = match row.faction.as_str() {
"TCAF" => egui::Color32::from_rgb(120, 170, 235),
"ADAN" => egui::Color32::from_rgb(235, 120, 120),
_ => egui::Color32::from_rgb(180, 180, 180),
};
egui::CollapsingHeader::new(
egui::RichText::new(format!("{} · {}", row.name, row.id)).color(color),
)
.id_salt(&row.id)
.default_open(false)
.show(ui, |ui| {
egui::Grid::new(("shipstat", &row.id)).num_columns(2).show(ui, |ui| {
ui.label("Faction");
ui.strong(&row.faction);
ui.end_row();
if let Some(hp) = row.hp {
ui.label("Hull HP");
ui.strong(format!("{hp:.0}"));
ui.end_row();
}
if let Some((x, y, z)) = row.size {
ui.label("Size (m)");
ui.strong(format!("{x:.0} × {y:.0} × {z:.0}"));
ui.end_row();
}
if row.turrets.is_some() || row.shield_gens.is_some() {
ui.label("Hardpoints");
ui.strong(format!(
"{} turrets · {} bridges · {} shield gens",
fint(row.turrets),
fint(row.bridges),
fint(row.shield_gens),
));
ui.end_row();
}
ui.label("Model parts");
ui.strong(format!("{}", row.parts.len()));
ui.end_row();
ui.label("Appears in");
ui.strong(row.stages.join(", "));
ui.end_row();
});
let btn = egui::Button::new(if is_sel {
"● Showing in 3D view"
} else {
"▶ Assemble & view in 3D"
});
if ui.add(btn).clicked() {
to_render = Some((
row.id.clone(),
row.stage_file.clone(),
format!("{} ({})", row.name, row.id),
));
}
});
}
});
if let Some((id, file, label)) = to_render {
let id2 = id.clone();
*selected = Some(id);
render.send(RequestShipRender { file, id: id2, external: show_external, label });
}
});
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();
// Widens the enumeration to everything `compose` can draw — the
// same rule the pak browser's inline preview uses. Costs a
// re-scan, so it is a deliberate click rather than the default.
if ui
.checkbox(&mut screens.include_fragments, "Fragments")
.on_hover_text(
"Also list RATC bundles with no .rat layout child: the developer-logo \
splash, and ~1 786 two-element fragments (a button beside its glow). \
These are what the PAK browser preview draws but this list omits.",
)
.changed()
{
screens.rescan = true;
}
});
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| {
if pak.truncated {
ui.colored_label(
egui::Color32::from_rgb(224, 168, 86),
"⚠ list truncated (decode budget)",
);
}
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 ui
.checkbox(&mut screens.black_backdrop, "Black backdrop")
.on_hover_text(
"Composite over black instead of the default dim slate. The slate \
stands in for the PRMD dim quad plus the live 3D scene behind an \
in-mission screen; black is what the game composites over on a \
screen carrying its own background — and what a framebuffer \
capture must be compared against.",
)
.changed()
{
recompose = true;
}
if ui
.checkbox(&mut screens.show_primitives, "Primitives")
.on_hover_text(
"Draw the untextured .prm fade/dim/flash quads. Decoded, but their \
paint order is unsolved: a primitive has no T8aD header and so no \
layer key, and the derived order forces keyless elements last. \
That is measurably wrong — expect an opaque quad to wipe some \
screens (32 of GP_DIALOG's builds).",
)
.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,
// The catalog stored (pak entry index, size) per build; the
// entry index is what lets the worker read one entry.
entry: pak
.builds
.get(screens.build)
.map(|(e, _)| *e)
.unwrap_or(0),
focus: screens.show_focus,
animated: screens.show_animated,
black_backdrop: screens.black_backdrop,
primitives: screens.show_primitives,
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>,
) {
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();
}
});
});
});
// A flag, not a self-send: holding both an EventReader and an EventWriter
// for RequestSaveOpen in one system is a Bevy B0002 panic at startup.
if reopen {
saves.request_open = true;
}
saves.open &= open;
}
// ── Cutscene browser (View ▸ Cutscenes) ──────────────────────────────────────
/// The cutscene catalog: every slot the manifest binds, with its movie,
/// subtitle track, voice token and telop overlay — and a transcript pane that
/// resolves the captions to text **without playing the video**.
///
/// Before this the manifest was invisible plumbing: it resolved a voice bank and
/// was never rendered, so a cutscene could only be found by hunting `.wmv` in
/// the ISO tree, where nothing says which mission a file belongs to.
#[cfg(not(target_arch = "wasm32"))]
fn draw_cutscenes_ui(
mut contexts: EguiContexts,
mut cut: ResMut<CutsceneBrowser>,
mut requests: EventReader<RequestCutscenes>,
mut file_selected: EventWriter<FileSelected>,
mut browser: ResMut<FileBrowserState>,
mut audio: ResMut<AudioPreview>,
mut compose_audio: EventWriter<RequestAudio>,
) {
if requests.read().next().is_some() {
cut.open = true;
}
if !cut.open {
return;
}
let ctx = contexts.ctx_mut().clone();
let mut open = true;
let mut pick: Option<usize> = None;
let mut play: Option<String> = None;
let mut play_voice: Option<String> = None;
egui::Window::new("🎬 Cutscenes")
.default_width(880.0)
.default_height(560.0)
.open(&mut open)
.show(&ctx, |ui| {
if cut.loading {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Reading the cutscene manifest…");
});
ctx.request_repaint();
return;
}
if !cut.loaded {
ui.label("Open a game source first (File ▸ Open…).");
return;
}
ui.horizontal(|ui| {
ui.label("🔍");
ui.text_edit_singleline(&mut cut.filter);
if !cut.filter.is_empty() && ui.small_button("✖").clicked() {
cut.filter.clear();
}
ui.separator();
ui.label("Subtitles:");
let before = cut.lang;
egui::ComboBox::from_id_salt("cutscene_lang")
.selected_text(cut.lang.label())
.show_ui(ui, |ui| {
for l in sylpheed_formats::movie_subtitle::SubLang::ALL {
ui.selectable_value(&mut cut.lang, l, l.label());
}
});
if cut.lang != before && cut.selected.is_some() {
cut.want_cues = true;
}
});
let missing = cut.rows.iter().filter(|r| !r.present).count();
ui.label(
egui::RichText::new(format!(
"{} slots · {} distinct movies{}",
cut.rows.len(),
cut.rows
.iter()
.map(|r| r.movie.as_str())
.collect::<std::collections::BTreeSet<_>>()
.len(),
if missing > 0 {
format!(" · ⚠ {missing} bound to a movie not on the disc")
} else {
String::new()
}
))
.weak()
.small(),
);
ui.separator();
let filter = cut.filter.to_lowercase();
egui::SidePanel::left("cutscene_list")
.resizable(true)
.default_width(330.0)
.show_inside(ui, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
egui::Grid::new("cutscene_grid")
.striped(true)
.num_columns(2)
.show(ui, |ui| {
for (i, r) in cut.rows.iter().enumerate() {
let hay = format!("{} {} {}", r.slot, r.movie, r.kind)
.to_lowercase();
if !filter.is_empty() && !hay.contains(&filter) {
continue;
}
let label = match (r.mission, r.phase) {
(Some(m), Some(p)) => format!("S{m:02} ph{p} {}", r.movie),
(Some(m), None) => format!("S{m:02} {}", r.movie),
_ => format!(" {}", r.movie),
};
let mut text = egui::RichText::new(label).monospace();
if !r.present {
text = text.color(egui::Color32::from_rgb(224, 86, 122));
}
if ui
.selectable_label(cut.selected == Some(i), text)
.on_hover_text(&r.slot)
.clicked()
{
pick = Some(i);
}
ui.label(
egui::RichText::new(r.kind).weak().small(),
);
ui.end_row();
}
});
});
});
egui::CentralPanel::default().show_inside(ui, |ui| {
let Some(row) = cut.selected.and_then(|i| cut.rows.get(i)) else {
ui.label("Pick a cutscene on the left.");
return;
};
ui.horizontal(|ui| {
ui.heading(&row.slot);
if row.present {
if ui.button("▶ Play").clicked() {
play = Some(format!("dat/movie/{}.wmv", row.movie));
}
} else {
ui.colored_label(
egui::Color32::from_rgb(224, 86, 122),
"⚠ not on the disc",
);
}
});
egui::Grid::new("cutscene_detail")
.num_columns(2)
.striped(true)
.show(ui, |ui| {
let mut kv = |k: &str, v: String| {
ui.label(egui::RichText::new(k).weak().small());
ui.label(egui::RichText::new(v).monospace());
ui.end_row();
};
kv("kind", row.kind.to_string());
kv("movie", format!("dat/movie/{}.wmv", row.movie));
if let (Some(m), Some(p)) = (row.mission, row.phase) {
kv("mission", format!("S{m:02}, phase {p}"));
} else if let Some(m) = row.mission {
kv("mission", format!("S{m:02}"));
}
kv(
"subtitle",
row.subtitle.clone().unwrap_or_else(|| "—".into()),
);
kv(
"voice track",
row.voice_token.clone().unwrap_or_else(|| "—".into()),
);
// The .prt overlay is bound here but we have no parser,
// so name the reference and say the content is not read.
kv(
"telop (.prt)",
match &row.telop {
Some(t) => format!("{t} (not decoded)"),
None => "—".into(),
},
);
});
ui.separator();
// ── Voice ───────────────────────────────────────────────────
// Two locations, shown together because they disagree. The bank
// named after a movie is the obvious place to look and is often
// not where the audio is: RT01A's voice sits inside the byte
// range of the entry named VOICE_ADV.slb. Playing the
// name-matched bank would be confidently wrong for exactly the
// cutscenes where it matters.
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Voice").strong());
if let Some(v) = &cut.voice {
if v.region.is_some() && ui.button("▶ Play").clicked() {
play_voice = Some(row.movie.clone());
}
if v.region.is_none() {
ui.colored_label(
egui::Color32::from_rgb(224, 168, 86),
"no region resolves — this cutscene may be unvoiced",
);
}
} else if cut.cues_loading {
ui.spinner();
}
});
if let Some(v) = &cut.voice {
egui::Grid::new("cutscene_voice")
.num_columns(2)
.striped(true)
.show(ui, |ui| {
let mut kv2 = |k: &str, val: String, warn: bool| {
ui.label(egui::RichText::new(k).weak().small());
let t = egui::RichText::new(val).monospace();
ui.label(if warn {
t.color(egui::Color32::from_rgb(224, 168, 86))
} else {
t
});
ui.end_row();
};
kv2("named bank", v.named_bank.clone(), false);
kv2(
"its byte range",
match v.named_range {
Some((a, b)) => format!("{a} .. {b} ({} B)", b - a),
None => "not in the TOC".into(),
},
false,
);
let honest = v.name_is_honest();
kv2(
"resolved region",
match v.region {
Some((a, b)) => format!("{a} .. {b} ({} B)", b - a),
None => "—".into(),
},
!honest && v.region.is_some(),
);
if v.region.is_some() {
kv2(
"name honest?",
if honest {
"yes — the audio is inside its own bank".into()
} else {
"NO — the audio is outside the bank named after this movie"
.into()
},
!honest,
);
}
});
}
ui.separator();
ui.label(egui::RichText::new("Transcript").strong());
if cut.cues_loading {
ui.horizontal(|ui| {
ui.spinner();
ui.label("resolving captions…");
});
ctx.request_repaint();
} else if cut.cues.is_empty() {
ui.label(
egui::RichText::new(
"no captions resolved for this movie in this language",
)
.weak(),
);
} else {
egui::ScrollArea::vertical()
.id_salt("cue_scroll")
.show(ui, |ui| {
egui::Grid::new("cue_grid").num_columns(2).striped(true).show(
ui,
|ui| {
for c in &cut.cues {
ui.label(
egui::RichText::new(fmt_time(c.start))
.monospace()
.weak()
.small(),
);
ui.label(&c.text);
ui.end_row();
}
},
);
});
}
});
});
if let Some(i) = pick {
cut.selected = Some(i);
cut.want_cues = true;
}
if let Some(movie) = play_voice {
// Routed through the movie form of RequestAudio, which resolves the
// continuous region rather than reading the name-matched bank.
audio.generation = audio.generation.wrapping_add(1);
audio.loading = true;
audio.active = true;
audio.error = None;
audio.name = format!("VOICE_{movie}");
let vlang = match cut.lang.pak_code() {
"jpn" => sylpheed_formats::slb::VoiceLang::Japanese,
_ => sylpheed_formats::slb::VoiceLang::English,
};
compose_audio.send(RequestAudio {
clip: String::new(),
display: format!("VOICE_{movie}"),
movie: Some((movie, vlang)),
mono: true,
generation: audio.generation,
});
}
if let Some(path) = play {
// Route through the normal file-open path, so the existing video player
// handles it exactly as it would from the tree.
browser.loading = true;
browser.selected = browser.files.iter().position(|f| f.eq_ignore_ascii_case(&path));
file_selected.send(FileSelected(path));
}
cut.open = open;
}