//! 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::{ FileInfo, FileSelected, IsoState, PakView, RequestOpenDir, RequestOpenIso, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, IsoLoaderSystemSet, }; use crate::ViewerState; 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)); } } /// 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())); } } } 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, mut video: ResMut, file_info: Res, mut open_iso_events: EventWriter, mut open_dir_events: EventWriter, mut file_selected_events: EventWriter, ) { 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() { open_iso_events.send_default(); ui.close_menu(); } if ui.button("Open extracted folder…").clicked() { open_dir_events.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("Help", |ui| { if ui.button("Controls…").clicked() { // TODO: controls popup } if ui.button("About").clicked() { // TODO: about dialog } }); }); }); // ── 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 file_selected_events, &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 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. 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 { draw_video_player(ui, &mut video); } 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 — Milestone 1"); 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("Click a .xpr file in the left panel to preview the texture."); }); ui.collapsing("Reverse Engineering Notes", |ui| { ui.label("Document your findings here as you RE the formats."); ui.separator(); egui::Grid::new("re_notes").striped(true).show(ui, |ui| { ui.strong("Extension"); ui.strong("Status"); ui.strong("Notes"); ui.end_row(); ui.label(".xpr"); ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Partial"); ui.label("XPR2 container + DXT1/3/5 textures"); ui.end_row(); ui.label("?"); ui.colored_label(egui::Color32::YELLOW, "⏳ TODO"); ui.label("Mesh format — need to identify extension"); ui.end_row(); ui.label("?"); ui.colored_label(egui::Color32::YELLOW, "⏳ TODO"); ui.label("Audio format — likely XWB wave banks"); ui.end_row(); ui.label("?"); ui.colored_label(egui::Color32::YELLOW, "⏳ TODO"); ui.label("Mission/level data — format unknown"); ui.end_row(); }); }); }); } } // ── Data pack (IPFB / IDXD) browser ─────────────────────────────────────────── /// Master-detail view for an open `.pak`: entry list on the left, the selected /// entry's IDXD schema + property table on the right. 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(); // Clone rows so the two column closures can read them while we record the // click into a local (avoids borrowing `pak` mutably inside the closures). let rows = pak.rows.clone(); let selected = pak.selected; let mut new_selection = None; 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() { let text = egui::RichText::new(format!( "{:08x} {:<7} {}", row.hash, row.format, row.identity )) .monospace(); if ui .add(egui::SelectableLabel::new(selected == Some(i), text)) .clicked() { new_selection = Some(i); } } }); // Right — detail of the selected entry. 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.detail { Some(d) => { let title = if row.identity.is_empty() { row.format.as_str() } else { row.identity.as_str() }; ui.heading(title); 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)); if !row.identity.is_empty() { ui.label(&row.identity); } ui.separator(); ui.label("Not an IDXD object — no field table."); } }, }); }); if new_selection.is_some() { pak.selected = new_selection; } } // ── 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) { // ── 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"); } }); 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); 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; } }); }); } // ── 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), ); }); } // ── 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(); } } }); }); }