Two changes after user feedback that externals sit near-but-wrong (shield inset into the hull, thruster floating close): - Scale: the joint table's slots 5–7 are per-axis scale (a GN_ShieldG frame ships at 0.5, GN_Jet FX at 2.4–6.5) and were being ignored, rendering scaled parts at the wrong size. ScenePart now carries `s` and applies R·(S·v)+T. - External placement is fundamentally approximate: a GN_* frame is the mount PIVOT (f105's two GN_ShieldG frames are both on the centreline), while the part's outboard/rotational offset lives in a detail sub-rig / runtime code this static pass can't recover. So assemble_ship gains `include_external`: the hull tier (rou_ nodes) is exact and always drawn; the external tier (bridge / shield / engine at GN_ frames) is opt-in via a "Show external parts" checkbox in the Ships browser, off by default so the clean, correct hull is the default view. The module doc is corrected to the scene-graph mechanism (the old "draw parts untransformed" claim was wrong). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1694 lines
68 KiB
Rust
1694 lines
68 KiB
Rust
//! 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, RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestVoiceLibrary,
|
||
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));
|
||
app.add_systems(Update, draw_game_data_ui.after(IsoLoaderSystemSet));
|
||
app.add_systems(Update, draw_ships_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>,
|
||
voice_lib: EventWriter<'w, RequestVoiceLibrary>,
|
||
game_data: EventWriter<'w, RequestGameData>,
|
||
ships: EventWriter<'w, RequestShipCatalog>,
|
||
}
|
||
|
||
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 voice_lib: ResMut<VoiceLibrary>,
|
||
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();
|
||
}
|
||
});
|
||
|
||
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 `<lang>\<dir>\<file>.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 — 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 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() {
|
||
let text = egui::RichText::new(format!(
|
||
"{:08x} {:<7} {}",
|
||
row.hash,
|
||
row.format,
|
||
row_kind(row)
|
||
))
|
||
.monospace();
|
||
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) => draw_ratc_detail(ui, row, children, img_tex),
|
||
PakContent::Text { text, encoding } => {
|
||
draw_text_detail(ui, row, text, encoding)
|
||
}
|
||
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) => format!("RATC · {} item(s)", c.len()),
|
||
PakContent::Text { .. } => "text".into(),
|
||
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);
|
||
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],
|
||
img_tex: &mut ImgCache,
|
||
) {
|
||
ui.horizontal(|ui| {
|
||
ui.heading("RATC bundle");
|
||
ui.separator();
|
||
ui.label(format!("{} item(s)", children.len()));
|
||
ui.separator();
|
||
ui.weak("colours unverified");
|
||
});
|
||
ui.separator();
|
||
|
||
let refs: Vec<&ImageRgba> = children.iter().filter_map(|c| c.image.as_ref()).collect();
|
||
ensure_textures(ui, row.hash, &refs, img_tex);
|
||
|
||
let mut img_i = 0;
|
||
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),
|
||
);
|
||
}
|
||
|
||
// ── 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 {
|
||
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<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, "Show external parts");
|
||
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;
|
||
}
|