Files
Syplheed-Reborn/crates/sylpheed-viewer/src/ui.rs
MechaCat02 79c78cc7e1 ship: apply scale + gate approximate external parts behind a toggle
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>
2026-07-23 22:11:47 +02:00

1694 lines
68 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! egui-based debug UI for the asset viewer.
//!
//! Provides panels for:
//! - File browser (list ISO / extracted game files, filter by name)
//! - Texture inspector (preview + format info)
//! - File info (size, detected format for non-texture files)
//! - RE notes (track discoveries during reverse engineering)
use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{
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;
}