//! 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::{ AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview, MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir, RequestOpenIso, RequestSaveOpen, RequestScreenCatalog, RequestScreenCompose, RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestVoiceLibrary, SaveBrowser, ScreenBrowser, ShipBrowser, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, VoiceLibrary, IsoLoaderSystemSet, }; 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)); } } } /// State for the file browser panel. #[derive(Resource, Default)] pub struct FileBrowserState { pub files: Vec, pub selected: Option, 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, /// 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) { let mut comps: Vec = 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, loading: &mut bool, events: &mut EventWriter, 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>, 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( mut contexts: EguiContexts, mut viewer: ResMut, mut browser: ResMut, iso_state: Res, preview: Res, text_preview: Res, mut pak_view: ResMut, skybox: Res, model: Res, mut video: ResMut, file_info: Res, mut subtitles: ResMut, mut movie_voice: ResMut, mut audio: ResMut, mut voice_lib: ResMut, 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("🎙 Voice Lines…").clicked() { voice_lib.open = true; if !voice_lib.loaded && !voice_lib.loading { voice_lib.loading = true; events.voice_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("💾 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 voice_lib.open { let mut open = true; egui::Window::new("🎙 Voice Lines") .default_width(360.0) .default_height(480.0) .open(&mut open) .show(ctx, |ui| { if voice_lib.loading { ui.horizontal(|ui| { ui.spinner(); ui.label("Reading sounds.tbl…"); }); ctx.request_repaint(); } else if !voice_lib.loaded { ui.label("Open a game source first."); } else { ui.horizontal(|ui| { ui.label("Filter:"); ui.text_edit_singleline(&mut voice_lib.filter); }); let f = voice_lib.filter.to_lowercase(); // Group the thousands of entries as directory → speaker so the // list is navigable (e.g. browse `Voice` by character to find a // cutscene's radio line). `name` is `\\.slb`. use std::collections::BTreeMap; let mut groups: BTreeMap<&str, BTreeMap<&str, Vec<&sylpheed_formats::slb::VoiceClip>>> = BTreeMap::new(); for c in &voice_lib.clips { if !f.is_empty() && !c.name.to_lowercase().contains(&f) { continue; } let dir = c.name.rsplit('\\').nth(1).unwrap_or("?"); groups .entry(dir) .or_default() .entry(c.speaker.as_str()) .or_default() .push(c); } let shown: usize = groups.values().flat_map(|s| s.values()).map(Vec::len).sum(); ui.label( egui::RichText::new(format!("{shown} / {} clips", voice_lib.clips.len())) .weak() .small(), ); ui.separator(); let filtering = !f.is_empty(); egui::ScrollArea::vertical().show(ui, |ui| { for (dir, speakers) in &groups { let dtotal: usize = speakers.values().map(Vec::len).sum(); egui::CollapsingHeader::new(format!("📁 {dir} ({dtotal})")) .id_salt(("vdir", *dir)) .default_open(filtering) .show(ui, |ui| { for (speaker, clips) in speakers { egui::CollapsingHeader::new(format!( "{speaker} ({})", clips.len() )) .id_salt(("vspk", *dir, *speaker)) .default_open(filtering || clips.len() <= 6) .show(ui, |ui| { for c in clips { ui.horizontal(|ui| { if ui .button("▶") .on_hover_text(&c.name) .clicked() { audio.generation = audio.generation.wrapping_add(1); audio.loading = true; audio.active = true; audio.name = c.display.clone(); events.audio.send(RequestAudio { clip: c.name.clone(), display: c.display.clone(), movie: None, generation: audio.generation, }); } ui.label(&c.display); }); } }); } }); } }); } }); voice_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.name = format!("VOICE_{movie}"); events.audio.send(RequestAudio { clip: String::new(), display: format!("VOICE_{movie}"), movie: Some((movie.clone(), movie_voice.lang)), 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)>; /// 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::>() .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 { ui.label("voice track"); } 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) -> String { match v { Some(x) if x == x.trunc() => format!("{}", x as i64), Some(x) => format!("{x}"), None => "·".into(), } } fn fint(v: Option) -> 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, mut requests: EventReader, ) { 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, mut requests: EventReader, mut render: EventWriter, ) { 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, mut requests: EventReader, mut compose: EventWriter, ) { if requests.read().next().is_some() { screens.open = true; } if !screens.open { return; } let ctx = contexts.ctx_mut().clone(); let mut open = true; // Set when a control changes; recomposing is a worker round-trip, so it is // requested once at the end rather than from inside the widget closures. let mut recompose = false; let mut pick: Option<(usize, usize)> = None; egui::Window::new("🖼 UI Screens") .default_width(1120.0) .default_height(720.0) .open(&mut open) .show(&ctx, |ui| { if screens.loading { ui.horizontal(|ui| { ui.spinner(); ui.label("Scanning the screen paks…"); }); ctx.request_repaint(); return; } if !screens.loaded { ui.label("Open a game source first (File ▸ Open…)."); return; } ui.horizontal(|ui| { ui.label("🔍"); ui.text_edit_singleline(&mut screens.filter); if !screens.filter.is_empty() && ui.small_button("✖").clicked() { screens.filter.clear(); } }); ui.separator(); let filter = screens.filter.to_lowercase(); egui::SidePanel::left("screen_list") .resizable(true) .default_width(240.0) .show_inside(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { for (pi, pak) in screens.paks.iter().enumerate() { if !filter.is_empty() && !pak.label.to_lowercase().contains(&filter) { continue; } // One pak per screen; each build inside it is one // (context × language) variant, so they are listed // separately rather than collapsed. egui::CollapsingHeader::new(&pak.label) .default_open(pak.builds.len() == 1) .show(ui, |ui| { for (bi, (entry, size)) in pak.builds.iter().enumerate() { let selected = screens.selected == Some(pi) && screens.build == bi; let label = format!( "build {bi} · entry {entry} · {} KB", size / 1024 ); if ui.selectable_label(selected, label).clicked() { pick = Some((pi, bi)); } } }); } }); }); egui::CentralPanel::default().show_inside(ui, |ui| { ui.horizontal(|ui| { if ui.checkbox(&mut screens.show_focus, "Focused states").changed() { recompose = true; } if ui .checkbox(&mut screens.show_animated, "Loop animations") .changed() { recompose = true; } if screens.composing { ui.spinner(); ctx.request_repaint(); } }); ui.separator(); // Split the borrow: the element grid mutates `hidden` and the // image block mutates `tex` while `composed` is read. let ScreenBrowser { composed, tex, hidden, generation, selected_element, .. } = &mut *screens; let generation = *generation; let Some(result) = composed.as_ref() else { ui.label("Pick a screen build on the left."); return; }; if let Some(err) = &result.error { ui.colored_label(egui::Color32::from_rgb(224, 86, 122), err); return; } if result.from_fallback { ui.colored_label( egui::Color32::from_rgb(224, 168, 86), "⚠ Recovered from .rat records — the declaration table was \ unusable, so elements without a record are missing.", ); } if !result.missing.is_empty() { ui.colored_label( egui::Color32::from_rgb(224, 168, 86), format!("⚠ sprites that did not resolve: {:?}", result.missing), ); } // The composite, scaled to fit. if let Some(img) = &result.image { let need = tex.as_ref().map(|(g, _)| *g) != Some(generation); if need { let ci = egui::ColorImage::from_rgba_unmultiplied( [img.width as usize, img.height as usize], &img.rgba, ); let handle = ctx.load_texture("ui_screen", ci, egui::TextureOptions::LINEAR); *tex = Some((generation, handle)); } if let Some((_, handle)) = &*tex { let avail = ui.available_width().min(880.0); let scale = (avail / img.width as f32).min(1.0); ui.add(egui::Image::new(handle).fit_to_exact_size(egui::vec2( img.width as f32 * scale, img.height as f32 * scale, ))); } } ui.separator(); let drawn = result.elements.iter().filter(|e| e.drawn).count(); ui.label(format!( "{}×{} · {} of {} elements drawn (declaration order = back to front)", result.design.0, result.design.1, drawn, result.elements.len() )); egui::ScrollArea::vertical() .max_height(220.0) .auto_shrink([false, false]) .show(ui, |ui| { egui::Grid::new("g_screen_elems") .striped(true) .num_columns(7) .show(ui, |ui| { for h in ["", "#", "Element", "Parent", "Kind", "Pivot", "Rest / kf"] { ui.strong(h); } ui.end_row(); for el in &result.elements { let mut vis = !hidden.get(el.index).copied().unwrap_or(false); if ui.checkbox(&mut vis, "").changed() { if hidden.len() <= el.index { hidden.resize(el.index + 1, false); } hidden[el.index] = !vis; recompose = true; } ui.label(el.index.to_string()); let name = if el.drawn { egui::RichText::new(&el.name) } else { egui::RichText::new(&el.name).weak() }; if ui .selectable_label( *selected_element == Some(el.index), name, ) .clicked() { *selected_element = Some(el.index); } ui.label( el.parent .map(|p| p.to_string()) .unwrap_or_else(|| "·".into()), ); ui.label(format!("{:#x}", el.kind)); ui.label(format!("{},{}", el.pivot.0, el.pivot.1)); ui.label(match el.rest { // The resting pose is the max-dwell // keyframe, not the first or the last. Some((x, y)) => { format!("({x},{y}) · {} kf", el.keyframes) } None => "·".into(), }); ui.end_row(); } }); }); if let Some(sel) = *selected_element { if let Some(el) = result.elements.iter().find(|e| e.index == sel) { ui.separator(); ui.strong(&el.name); if let Some(s) = &el.sprite { ui.label(format!("sprite: {s}")); } if let Some(link) = &el.focus_link { ui.label(format!("focused state: {link}")); } } } }); }); if let Some((pi, bi)) = pick { screens.selected = Some(pi); screens.build = bi; screens.hidden.clear(); screens.selected_element = None; recompose = true; } if recompose { if let Some(pi) = screens.selected { if let Some(pak) = screens.paks.get(pi) { compose.send(RequestScreenCompose { pak: pak.path.clone(), build: screens.build, focus: screens.show_focus, animated: screens.show_animated, hidden: screens.hidden.clone(), }); } } } screens.open &= open; } // ── Save-file inspector (View ▸ Save File) ─────────────────────────────────── /// Colour a field by how well it is actually understood, so the panel cannot be /// read as "all of this is solved" — eleven GHAD words are not. #[cfg(not(target_arch = "wasm32"))] fn confidence_color(c: sylpheed_formats::savegame::Confidence) -> egui::Color32 { use sylpheed_formats::savegame::Confidence::*; match c { Confirmed => egui::Color32::from_rgb(120, 200, 140), Probable => egui::Color32::from_rgb(224, 196, 110), Unknown => egui::Color32::GRAY, Refuted => egui::Color32::from_rgb(224, 110, 110), } } #[cfg(not(target_arch = "wasm32"))] fn confidence_mark(c: sylpheed_formats::savegame::Confidence) -> &'static str { use sylpheed_formats::savegame::Confidence::*; match c { Confirmed => "✔", Probable => "~", Unknown => "?", Refuted => "✖", } } #[cfg(not(target_arch = "wasm32"))] fn draw_save_ui( mut contexts: EguiContexts, mut saves: ResMut, mut requests: EventReader, ) { if requests.read().next().is_some() { saves.open = true; } if !saves.open { return; } let ctx = contexts.ctx_mut().clone(); let mut open = true; let mut reopen = false; egui::Window::new("💾 Save File") .default_width(720.0) .default_height(620.0) .open(&mut open) .show(&ctx, |ui| { ui.horizontal(|ui| { if ui.button("Open savedata…").clicked() { reopen = true; } ui.checkbox(&mut saves.show_unknown, "Show unidentified fields"); if saves.loading { ui.spinner(); ctx.request_repaint(); } }); ui.separator(); let Some(r) = saves.result.as_ref() else { ui.label( "A save lives in the emulator's content tree, not on the disc:\n\ //535107D4/00000001/game01/savedata", ); return; }; if let Some(err) = &r.error { ui.colored_label(egui::Color32::from_rgb(224, 86, 122), err); return; } ui.label(&r.path); ui.label(format!( "GDHA · {} B header + {} B deflate → {} B payload · phase {}", r.header_len, r.deflate_len, r.payload_len, r.phase )); // The round-trip is the check the whole layout rests on: the title // writes its struct field-by-field with no packing, so a correct // parse must reproduce the payload exactly. if r.round_trips { ui.colored_label( egui::Color32::from_rgb(120, 200, 140), "✔ re-serializes byte-identically", ); } else { ui.colored_label( egui::Color32::from_rgb(224, 86, 122), "✖ round-trip MISMATCH — this parse is wrong", ); } ui.separator(); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { ui.strong("GHAD progress block"); egui::Grid::new("g_save_ghad").striped(true).num_columns(4).show(ui, |ui| { for h in ["", "Offset", "Field", "Value"] { ui.strong(h); } ui.end_row(); for f in &r.fields { use sylpheed_formats::savegame::Confidence; if !saves.show_unknown && f.confidence == Confidence::Unknown { continue; } let col = confidence_color(f.confidence); ui.colored_label(col, confidence_mark(f.confidence)); ui.label(format!("+{}", f.offset)); let label = ui.colored_label(col, &f.name); if !f.note.is_empty() { label.on_hover_text(&f.note); } ui.label(&f.value); ui.end_row(); } }); ui.add_space(8.0); ui.strong("Arsenal development"); { use sylpheed_formats::savegame::DevelopState; let owned = r.develop.iter().filter(|(_, d)| *d == DevelopState::Developed).count(); let ready = r.develop.iter().filter(|(_, d)| *d == DevelopState::Developable).count(); ui.label(format!( "{owned} developed · {ready} developable · {} locked (index space = \ strings.tbl item order, cut items included)", r.develop.len() - owned - ready )); ui.horizontal_wrapped(|ui| { for (i, d) in &r.develop { let (txt, col) = match d { DevelopState::Developed => ("4", egui::Color32::from_rgb(120, 200, 140)), DevelopState::Developable => ("2", egui::Color32::from_rgb(224, 196, 110)), DevelopState::Locked => ("0", egui::Color32::DARK_GRAY), DevelopState::Other(_) => ("?", egui::Color32::from_rgb(224, 110, 110)), }; ui.colored_label(col, txt).on_hover_text(format!("item {i}")); } }); } ui.add_space(8.0); ui.strong("Per-stage records"); ui.label( egui::RichText::new( "SHAB — these are the per-stage results, NOT the UI's save slots.", ) .weak(), ); egui::Grid::new("g_save_shab").striped(true).num_columns(4).show(ui, |ui| { for h in ["Stage", "difficulty ~", "points ?", "best time ✔"] { ui.strong(h); } ui.end_row(); for (stage, a, b, ms) in &r.records { ui.label(format!("{stage:02}")); ui.label(a.to_string()); ui.label(b.to_string()); ui.label(sylpheed_formats::savegame::fmt_millis(*ms)); ui.end_row(); } }); ui.add_space(8.0); ui.strong("Header summary"); ui.label( egui::RichText::new( "What the in-game Details panel actually reads. A payload edit that \ leaves these stale shows no change on the panel — which is not \ evidence that the payload field was the wrong one.", ) .weak(), ); egui::Grid::new("g_save_summary").striped(true).num_columns(4).show(ui, |ui| { for h in ["Header", "Field", "Value", "Payload"] { ui.strong(h); } ui.end_row(); for (off, name, value, payload) in &r.summary { ui.label(format!("{off:#04x}")); ui.label(name); ui.label(value.to_string()); match payload { Some(p) if u64::from(*value) == *p => { ui.colored_label(egui::Color32::from_rgb(120, 200, 140), "agrees") } Some(p) => ui.colored_label( egui::Color32::from_rgb(224, 110, 110), format!("STALE — payload has {p}"), ), None => ui.label("·"), }; ui.end_row(); } }); }); }); // 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; }