//! 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, pub selected: Option, pub filter: String, /// True from the moment a file is clicked until its content is applied — /// drives the "Loading…" indicator so the click registers immediately. pub loading: bool, } // ── Directory tree ──────────────────────────────────────────────────────────── /// One directory node in the ephemeral file tree. Rebuilt from the flat path /// list each frame; leaves carry their original index into `browser.files` so /// selection stays stable regardless of tree shape or filtering. #[derive(Default)] struct TreeDir { /// Child directories, keyed by name (`BTreeMap` = stable alphabetical order). dirs: std::collections::BTreeMap, /// Leaf files: (display name, original index into `browser.files`). files: Vec<(String, usize)>, } /// True for an IPFB data segment (`foo.p00`, `foo.p01`, …). These are the pack's /// data body, not standalone files — the `.pak` index is what the user opens, so /// they're hidden from the tree (their content loads when the `.pak` is opened). fn is_pak_segment(leaf: &str) -> bool { matches!(leaf.rsplit_once('.'), Some((_, ext)) if ext.len() == 3 && ext.as_bytes()[0] == b'p' && ext.as_bytes()[1].is_ascii_digit() && ext.as_bytes()[2].is_ascii_digit()) } /// Split a full path into (directory components, leaf name). fn split_for_tree(full: &str) -> (Vec, String) { let mut comps: Vec = full.split('/').map(str::to_string).collect(); let leaf = comps.pop().unwrap_or_default(); (comps, leaf) } /// Build the directory tree, keeping only leaves whose full path contains /// `filter` (case-insensitive; empty filter keeps everything). `.pNN` data /// segments are skipped — only their `.pak` index is shown. fn build_tree(files: &[String], filter: &str) -> TreeDir { let filter = filter.to_lowercase(); let mut root = TreeDir::default(); for (idx, full) in files.iter().enumerate() { if !filter.is_empty() && !full.to_lowercase().contains(&filter) { continue; } let (dirs, leaf) = split_for_tree(full); if is_pak_segment(&leaf) { continue; } let mut node = &mut root; for comp in dirs { node = node.dirs.entry(comp).or_default(); } node.files.push((leaf, idx)); } root } /// Render a directory node recursively: sub-directories (collapsible) first, /// then leaf files as selectable labels. #[allow(clippy::too_many_arguments)] fn render_dir( ui: &mut egui::Ui, node: &TreeDir, force_open: bool, selected: &mut Option, loading: &mut bool, events: &mut EventWriter, files: &[String], ) { for (name, child) in &node.dirs { egui::CollapsingHeader::new(format!("📁 {name}")) .id_salt(name) .default_open(force_open) .show(ui, |ui| { render_dir(ui, child, force_open, selected, loading, events, files); }); } for (leaf, idx) in &node.files { let is_selected = *selected == Some(*idx); if ui .add(egui::SelectableLabel::new(is_selected, leaf.as_str())) .clicked() { *selected = Some(*idx); *loading = true; // show the spinner until the content is applied events.send(FileSelected(files[*idx].clone())); } } } /// Bundled event writers for the UI, to keep `draw_viewer_ui` under Bevy's /// 16-parameter system limit. #[derive(bevy::ecs::system::SystemParam)] struct UiEvents<'w> { open_iso: EventWriter<'w, RequestOpenIso>, open_dir: EventWriter<'w, RequestOpenDir>, file_selected: EventWriter<'w, FileSelected>, subtitles: EventWriter<'w, RequestSubtitles>, audio: EventWriter<'w, RequestAudio>, voice_lib: EventWriter<'w, RequestVoiceLibrary>, game_data: EventWriter<'w, RequestGameData>, ships: EventWriter<'w, RequestShipCatalog>, } fn draw_viewer_ui( mut contexts: EguiContexts, mut viewer: ResMut, mut browser: ResMut, iso_state: Res, preview: Res, text_preview: Res, mut pak_view: ResMut, skybox: Res, model: Res, mut video: ResMut, file_info: Res, mut subtitles: ResMut, mut movie_voice: ResMut, mut audio: ResMut, mut voice_lib: ResMut, mut events: UiEvents, ) { let ctx = contexts.ctx_mut(); // ── Menu bar ────────────────────────────────────────────────────────── egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { egui::menu::bar(ui, |ui| { ui.menu_button("File", |ui| { #[cfg(not(target_arch = "wasm32"))] { if ui.button("Open ISO disc image…").clicked() { events.open_iso.send_default(); ui.close_menu(); } if ui.button("Open extracted folder…").clicked() { events.open_dir.send_default(); ui.close_menu(); } ui.separator(); if ui.button("Quit").clicked() { std::process::exit(0); } } #[cfg(target_arch = "wasm32")] ui.colored_label( egui::Color32::GRAY, "File loading not available in browser", ); }); ui.menu_button("View", |ui| { if ui.button("🎙 Voice Lines…").clicked() { voice_lib.open = true; if !voice_lib.loaded && !voice_lib.loading { voice_lib.loading = true; events.voice_lib.send_default(); } ui.close_menu(); } if ui.button("🗃 Game Data…").clicked() { // The Game Data window (its own system) reads this event to // open + lazily decode the tables. events.game_data.send_default(); ui.close_menu(); } if ui.button("🚀 Ships…").clicked() { // The Ships window (its own system) reads this event to open + // lazily scan the stage containers for assemblable ships. events.ships.send_default(); ui.close_menu(); } }); ui.menu_button("Help", |ui| { if ui.button("Controls…").clicked() { // TODO: controls popup } if ui.button("About").clicked() { // TODO: about dialog } }); }); }); // ── Standalone voice-line browser (floating window) ─────────────────── if voice_lib.open { let mut open = true; egui::Window::new("🎙 Voice Lines") .default_width(360.0) .default_height(480.0) .open(&mut open) .show(ctx, |ui| { if voice_lib.loading { ui.horizontal(|ui| { ui.spinner(); ui.label("Reading sounds.tbl…"); }); ctx.request_repaint(); } else if !voice_lib.loaded { ui.label("Open a game source first."); } else { ui.horizontal(|ui| { ui.label("Filter:"); ui.text_edit_singleline(&mut voice_lib.filter); }); let f = voice_lib.filter.to_lowercase(); // Group the thousands of entries as directory → speaker so the // list is navigable (e.g. browse `Voice` by character to find a // cutscene's radio line). `name` is `\\.slb`. use std::collections::BTreeMap; let mut groups: BTreeMap<&str, BTreeMap<&str, Vec<&sylpheed_formats::slb::VoiceClip>>> = BTreeMap::new(); for c in &voice_lib.clips { if !f.is_empty() && !c.name.to_lowercase().contains(&f) { continue; } let dir = c.name.rsplit('\\').nth(1).unwrap_or("?"); groups .entry(dir) .or_default() .entry(c.speaker.as_str()) .or_default() .push(c); } let shown: usize = groups.values().flat_map(|s| s.values()).map(Vec::len).sum(); ui.label( egui::RichText::new(format!("{shown} / {} clips", voice_lib.clips.len())) .weak() .small(), ); ui.separator(); let filtering = !f.is_empty(); egui::ScrollArea::vertical().show(ui, |ui| { for (dir, speakers) in &groups { let dtotal: usize = speakers.values().map(Vec::len).sum(); egui::CollapsingHeader::new(format!("📁 {dir} ({dtotal})")) .id_salt(("vdir", *dir)) .default_open(filtering) .show(ui, |ui| { for (speaker, clips) in speakers { egui::CollapsingHeader::new(format!( "{speaker} ({})", clips.len() )) .id_salt(("vspk", *dir, *speaker)) .default_open(filtering || clips.len() <= 6) .show(ui, |ui| { for c in clips { ui.horizontal(|ui| { if ui .button("▶") .on_hover_text(&c.name) .clicked() { audio.generation = audio.generation.wrapping_add(1); audio.loading = true; audio.active = true; audio.name = c.display.clone(); events.audio.send(RequestAudio { clip: c.name.clone(), display: c.display.clone(), movie: None, generation: audio.generation, }); } ui.label(&c.display); }); } }); } }); } }); } }); voice_lib.open = open; } // ── Left panel: file browser ────────────────────────────────────────── egui::SidePanel::left("file_browser") .resizable(true) .default_width(280.0) .show(ctx, |ui| { ui.heading("Game Files"); ui.separator(); // Filter input ui.horizontal(|ui| { ui.label("Filter:"); ui.text_edit_singleline(&mut browser.filter); }); ui.separator(); // File list egui::ScrollArea::vertical().show(ui, |ui| { if iso_state.loading { ui.spinner(); ui.label("Loading…"); } else if let Some(err) = &iso_state.error { ui.colored_label( egui::Color32::RED, format!("⚠ Error:\n{}", err), ); } else if browser.files.is_empty() { ui.colored_label( egui::Color32::YELLOW, "No files loaded.\nUse File → Open ISO disc image\n\ or File → Open extracted folder.", ); } else { // Build a directory tree from the flat path list. Cheap to // rebuild every frame (a few hundred short paths); cloning // `files` first sidesteps borrowing `browser` immutably and // mutably (selected) at the same time. let files = browser.files.clone(); let tree = build_tree(&files, &browser.filter); let force_open = !browser.filter.is_empty(); let FileBrowserState { selected, loading, .. } = &mut *browser; render_dir( ui, &tree, force_open, selected, loading, &mut events.file_selected, &files, ); } }); }); // ── Bottom panel: status bar ────────────────────────────────────────── egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| { // `horizontal_wrapped` so the bar wraps instead of overflowing the // window on narrow widths; the controls hint is right-aligned and only // the source's final path component is shown (full path on hover). ui.horizontal_wrapped(|ui| { if let Some(label) = &iso_state.source_label { let short = label.rsplit(['/', '\\']).next().unwrap_or(label); ui.label(short).on_hover_text(label); ui.separator(); } ui.label(format!("{} files", browser.files.len())); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label("LMB orbit · RMB pan · scroll zoom · R reset"); }); }); }); // ── Central area ────────────────────────────────────────────────────── if audio.active { // Standalone audio player takes over the central panel. egui::CentralPanel::default().show(ctx, |ui| { ctx.request_repaint(); draw_audio_player(ui, &mut audio); }); } else if browser.selected.is_some() && model.active && !browser.loading { // 3D model: draw the central panel TRANSPARENT so the real Bevy scene // (mesh + orbit camera) shows through, with just an info overlay on top. egui::CentralPanel::default() .frame(egui::Frame::none()) .show(ctx, |ui| { egui::Frame::none() .fill(egui::Color32::from_black_alpha(160)) .inner_margin(egui::Margin::same(8.0)) .rounding(egui::Rounding::same(4.0)) .show(ui, |ui| { ui.heading(&model.name); ui.label(&model.info); ui.colored_label( egui::Color32::from_gray(160), "3D preview · LMB orbit · RMB pan · scroll zoom · R reset", ); ui.colored_label( egui::Color32::from_gray(140), "position + normal + UV from the XBG7 vertex declaration", ); }); }); } else if browser.selected.is_some() { egui::CentralPanel::default().show(ctx, |ui| { if browser.loading { // A file was just clicked — show immediate feedback while the // background read + decode runs, instead of the previous item. // Keep repainting so the spinner animates even if the app is in a // reactive (idle) winit mode while the worker thread churns. ctx.request_repaint(); let name = browser .selected .and_then(|i| browser.files.get(i)) .map(|p| p.rsplit('/').next().unwrap_or(p)) .unwrap_or(""); ui.centered_and_justified(|ui| { ui.horizontal(|ui| { ui.spinner(); ui.label(format!("Loading {name}…")); }); }); } else if video.active { let mut lang_changed = false; let mut play_voice_solo = false; draw_video_player( ui, &mut video, &mut subtitles, &mut movie_voice, &mut lang_changed, &mut play_voice_solo, ); if play_voice_solo { if let Some(movie) = movie_voice.movie.clone() { video.playing = false; // pause the video; audio takes over audio.generation = audio.generation.wrapping_add(1); audio.loading = true; audio.active = true; // show the panel immediately (spinner) audio.name = format!("VOICE_{movie}"); events.audio.send(RequestAudio { clip: String::new(), display: format!("VOICE_{movie}"), movie: Some((movie.clone(), movie_voice.lang)), generation: audio.generation, }); } } if lang_changed { if let Some(movie) = subtitles.movie.clone() { subtitles.generation = subtitles.generation.wrapping_add(1); subtitles.cues.clear(); subtitles.loading = true; events.subtitles.send(RequestSubtitles { movie, lang: subtitles.lang, generation: subtitles.generation, }); } } } else if !skybox.faces.is_empty() { draw_skybox_grid(ui, &skybox); } else if pak_view.loaded { draw_pak_browser(ui, &mut pak_view); } else if let Some(text) = &text_preview.content { // ── Plain-text viewer ── ui.horizontal(|ui| { ui.heading(&file_info.name); ui.separator(); ui.label(&text_preview.encoding); ui.separator(); ui.checkbox(&mut viewer.text_wrap, "Wrap"); if text_preview.truncated { ui.separator(); ui.colored_label(egui::Color32::YELLOW, "(truncated)"); } }); ui.separator(); egui::ScrollArea::both().show(ui, |ui| { // Read-only: `&str` is a non-editable TextBuffer, and // `.interactive(false)` keeps it selectable/copyable. let mut buf = text.as_str(); let mut edit = egui::TextEdit::multiline(&mut buf) .font(egui::TextStyle::Monospace) .code_editor() .desired_width(f32::INFINITY) .interactive(false); if !viewer.text_wrap { // Don't wrap: let ScrollArea scroll horizontally. edit = edit.clip_text(false); } ui.add(edit); }); } else if let Some(egui_id) = preview.egui_id { // Texture preview ui.heading(&file_info.name); ui.label(&preview.format_info); if !preview.format_info.is_empty() && preview.format_info.contains("failed") { ui.colored_label(egui::Color32::YELLOW, &preview.format_info); } ui.separator(); // Scale to fit, preserving aspect ratio, never upscale let avail = ui.available_size(); let scale = if preview.width == 0 || preview.height == 0 { 1.0_f32 } else { (avail.x / preview.width as f32) .min(avail.y / preview.height as f32) .min(1.0) }; let display_w = preview.width as f32 * scale; let display_h = preview.height as f32 * scale; ui.add(egui::Image::new(egui::load::SizedTexture::new( egui_id, [display_w, display_h], ))); } else { // Non-texture file info ui.heading(&file_info.name); if !preview.format_info.is_empty() { ui.colored_label(egui::Color32::YELLOW, &preview.format_info); } if file_info.size_bytes > 0 { ui.separator(); ui.label(format!("Size: {} bytes", file_info.size_bytes)); if let Some(fmt) = file_info.detected_format { ui.label(format!("Detected format: {:?}", fmt)); } } else if iso_state.loading { ui.spinner(); ui.label("Loading file…"); } } }); } else { // No file selected — show welcome / RE notes egui::CentralPanel::default().show(ctx, |ui| { ui.heading("Project Sylpheed: Arc of Deception"); ui.label("Asset Viewer — 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)>; /// 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::>() .join("\n"); paint_caption(ui, rect, &joined); } } // ── Transport bar ── ui.horizontal(|ui| { if ui.button(if video.playing { "⏸" } else { "▶" }).clicked() { video.playing = !video.playing; } ui.label(fmt_time(video.position)); // Timeline fills the space left after reserving room for the total-time // label and the volume control on the right. Dragging scrubs live (the // grabber shows the frame under the knob); release commits the seek. let reserved = 170.0; let tl_width = (ui.available_width() - reserved).max(80.0); ui.spacing_mut().slider_width = tl_width; let mut pos = video.position; let resp = ui.add(egui::Slider::new(&mut pos, 0.0..=video.duration.max(0.1)).show_value(false)); if resp.changed() { video.position = pos; // knob + label follow immediately video.scrub_request = Some(pos); if resp.dragged() { video.scrubbing = true; } else { // A click on the track (no drag) → commit right away. video.seek_request = Some(pos); video.scrubbing = false; } } if resp.drag_stopped() { video.seek_request = Some(pos); video.scrubbing = false; } ui.label(fmt_time(video.duration)); ui.separator(); ui.label("🔊"); ui.spacing_mut().slider_width = 90.0; ui.add_enabled( video.has_audio, egui::Slider::new(&mut video.volume, 0.0..=1.0).show_value(false), ); }); } /// Draw a subtitle caption centered along the bottom of `rect`, with a /// semi-transparent backing box so it stays legible over any frame. fn paint_caption(ui: &egui::Ui, rect: egui::Rect, text: &str) { let painter = ui.painter_at(rect); // Font scales with the frame; clamped so it's readable but not huge. let size = (rect.height() * 0.045).clamp(13.0, 30.0); let font = egui::FontId::proportional(size); let wrap = rect.width() * 0.9; let galley = painter.layout( text.to_string(), font, egui::Color32::WHITE, wrap, ); let margin = egui::vec2(10.0, 6.0); let box_size = galley.size() + margin * 2.0; let top_left = egui::pos2( rect.center().x - box_size.x / 2.0, rect.bottom() - box_size.y - rect.height() * 0.04, ); let bg = egui::Rect::from_min_size(top_left, box_size); painter.rect_filled(bg, 4.0, egui::Color32::from_black_alpha(160)); painter.galley(top_left + margin, galley, egui::Color32::WHITE); } /// Standalone audio player: a transport bar for a decoded voice/sound track with /// no video. Playback state lives in `AudioPreview`; `advance_audio_playback` /// reacts to it. fn draw_audio_player(ui: &mut egui::Ui, audio: &mut AudioPreview) { ui.horizontal(|ui| { ui.heading(&audio.name); ui.separator(); if audio.loading { ui.spinner(); ui.label("decoding…"); } else { ui.label("voice track"); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("✖ Close").clicked() { audio.active = false; } }); }); ui.separator(); ui.add_space(ui.available_height() * 0.4); // Big centered play/pause + a speaker glyph, since there's nothing to show. ui.vertical_centered(|ui| { ui.label(egui::RichText::new("🔊").size(64.0)); }); ui.add_space(12.0); // Transport bar. ui.horizontal(|ui| { if ui .button(if audio.playing { "⏸" } else { "▶" }) .clicked() { audio.playing = !audio.playing; } ui.label(fmt_time(audio.position)); let tl_width = (ui.available_width() - 170.0).max(80.0); ui.spacing_mut().slider_width = tl_width; let mut pos = audio.position; let resp = ui.add( egui::Slider::new(&mut pos, 0.0..=audio.duration.max(0.1)).show_value(false), ); if resp.changed() { audio.position = pos; audio.seek_request = Some(pos); } ui.label(fmt_time(audio.duration)); ui.separator(); ui.label("🔊"); ui.spacing_mut().slider_width = 90.0; ui.add(egui::Slider::new(&mut audio.volume, 0.0..=1.0).show_value(false)); }); } // ── World cubemap (skybox) viewer ───────────────────────────────────────────── /// Show a world cubemap's 6 faces in a labelled grid (D3D9 order). A true /// interactive skybox is a follow-up — the face data + order are validated, but /// the wgpu cube-sampling handedness needs visual confirmation first. fn draw_skybox_grid(ui: &mut egui::Ui, skybox: &SkyboxPreview) { ui.horizontal(|ui| { ui.heading("World cubemap"); ui.separator(); ui.label(&skybox.info); }); ui.label("6 cube faces, D3D9 order (+X −X +Y −Y +Z −Z)."); ui.separator(); egui::ScrollArea::both().show(ui, |ui| { const CELL: f32 = 240.0; egui::Grid::new("cube_faces") .num_columns(3) .spacing([10.0, 10.0]) .show(ui, |ui| { for (i, face) in skybox.faces.iter().enumerate() { ui.vertical(|ui| { ui.strong(face.label); let aspect = face.height as f32 / face.width.max(1) as f32; ui.add(egui::Image::new(egui::load::SizedTexture::new( face.egui_id, [CELL, CELL * aspect], ))); }); if (i + 1) % 3 == 0 { ui.end_row(); } } }); }); } // ── Game Data browser ────────────────────────────────────────────────────────── fn fnum(v: Option) -> String { match v { Some(x) if x == x.trunc() => format!("{}", x as i64), Some(x) => format!("{x}"), None => "·".into(), } } fn fint(v: Option) -> String { v.map(|x| x.to_string()).unwrap_or_else(|| "·".into()) } /// The Game Data browser — a floating window over the decoded IDXD tables /// (weapons, craft, ships, cast, missions, arsenal, flights). Its own system so /// `draw_viewer_ui` stays under Bevy's system-parameter limit; a `RequestGameData` /// event (from the View menu) opens it and lazily kicks off decoding. #[cfg(not(target_arch = "wasm32"))] fn draw_game_data_ui( mut contexts: EguiContexts, mut game_data: ResMut, mut requests: EventReader, ) { if requests.read().next().is_some() { game_data.open = true; if !game_data.loaded { game_data.loading = true; } } if !game_data.open { return; } let ctx = contexts.ctx_mut().clone(); let mut open = true; egui::Window::new("🗃 Game Data") .default_width(700.0) .default_height(560.0) .open(&mut open) .show(&ctx, |ui| { if game_data.loading { ui.horizontal(|ui| { ui.spinner(); ui.label("Decoding game tables…"); }); ctx.request_repaint(); return; } if !game_data.loaded { ui.label("Open a game source first (File ▸ Open…)."); return; } let GameData { category, filter, snapshot, .. } = &mut *game_data; ui.horizontal_wrapped(|ui| { for cat in GameCategory::ALL { ui.selectable_value(category, cat, cat.label()); } }); let filterable = !matches!(category, GameCategory::Arsenal | GameCategory::Flights); if filterable { ui.horizontal(|ui| { ui.label("🔍"); ui.text_edit_singleline(filter); if !filter.is_empty() && ui.small_button("✖").clicked() { filter.clear(); } }); } ui.separator(); let f = filter.to_lowercase(); let hit = |s: &str| f.is_empty() || s.to_lowercase().contains(&f); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match *category { GameCategory::Weapons => { egui::Grid::new("g_weap").striped(true).num_columns(6).show(ui, |ui| { for h in ["Weapon", "Targets", "Power", "Velocity", "Range", "Reload"] { ui.strong(h); } ui.end_row(); for w in &snapshot.weapons { let name = w.id.as_deref().unwrap_or("?").trim_start_matches("Weapon_"); if !hit(name) { continue; } ui.label(name); ui.label(w.target_type.as_deref().unwrap_or("·")); ui.label(fnum(w.power)); ui.label(fnum(w.velocity)); ui.label(fnum(w.max_range)); ui.label(fint(w.loading_count)); ui.end_row(); } }); } GameCategory::Craft => { egui::Grid::new("g_craft").striped(true).num_columns(6).show(ui, |ui| { for h in ["Craft", "HP", "Cruise", "Accel", "Radar", "Turrets"] { ui.strong(h); } ui.end_row(); for u in &snapshot.craft { let name = u.id.as_deref().unwrap_or("?").trim_start_matches("UN_"); if !hit(name) { continue; } ui.label(name); ui.label(fnum(u.hp)); ui.label(fnum(u.cruising_velocity)); ui.label(fnum(u.acceleration)); ui.label(fnum(u.radar_range)); ui.label(fint(u.turret_count)); ui.end_row(); } }); } GameCategory::Vessels => { egui::Grid::new("g_ves").striped(true).num_columns(6).show(ui, |ui| { for h in ["Vessel", "HP", "Length", "Turrets", "Bridges", "Shield gen"] { ui.strong(h); } ui.end_row(); for v in &snapshot.vessels { let name = v.id.as_deref().unwrap_or("?").trim_start_matches("UN_"); if !hit(name) { continue; } ui.label(name); ui.label(fnum(v.hp)); ui.label(fnum(v.size_z)); ui.label(fint(v.turret_count)); ui.label(fint(v.bridge_count)); ui.label(fint(v.shield_generator_count)); ui.end_row(); } }); } GameCategory::Characters => { egui::Grid::new("g_char").striped(true).num_columns(3).show(ui, |ui| { for h in ["Name", "Faction", "Portraits"] { ui.strong(h); } ui.end_row(); for c in &snapshot.characters { if !hit(&c.name) && !hit(&c.faction) { continue; } ui.label(&c.name); let col = if c.faction == "TCAF" { egui::Color32::from_rgb(90, 160, 232) } else if c.faction == "ADAN" { egui::Color32::from_rgb(224, 86, 122) } else { egui::Color32::GRAY }; ui.colored_label(col, if c.faction.is_empty() { "—" } else { &c.faction }); ui.label(c.faces.to_string()); ui.end_row(); } }); } GameCategory::Missions => { for m in &snapshot.missions { if !hit(&m.id) && !hit(&m.location) && !m.objectives.iter().any(|o| hit(o)) { continue; } let head = format!("{} · {} · {} phases", m.id, m.location, m.phases); egui::CollapsingHeader::new(head).id_salt(&m.id).show(ui, |ui| { if !m.objectives.is_empty() { ui.strong("Objectives"); for o in &m.objectives { ui.label(format!("▸ {o}")); } } if !m.lose.is_empty() { ui.add_space(4.0); ui.strong("Fail conditions"); for l in &m.lose { ui.colored_label(egui::Color32::from_rgb(224, 86, 122), l); } } if !m.enemies.is_empty() { ui.add_space(4.0); ui.strong("Enemy roster"); for e in &m.enemies { ui.label(format!("• {e}")); } } }); } } GameCategory::Arsenal => { ui.columns(4, |cols| { for (i, (title, list)) in [ ("Nose", &snapshot.arsenal.nose), ("Arm 1", &snapshot.arsenal.arm1), ("Arm 2", &snapshot.arsenal.arm2), ("Arm 3", &snapshot.arsenal.arm3), ] .into_iter() .enumerate() { cols[i].strong(format!("{title} ({})", list.len())); for w in list { cols[i].label(w.replace('_', " ")); } } }); } GameCategory::Flights => { ui.label( egui::RichText::new("Distinct wingman line-ups (story order)").weak().small(), ); ui.add_space(4.0); for (n, lineup) in snapshot.flights.iter().enumerate() { egui::CollapsingHeader::new(format!("Line-up {}", n + 1)) .id_salt(n) .default_open(n == 0) .show(ui, |ui| { egui::Grid::new(("g_flight", n)).striped(true).num_columns(2).show(ui, |ui| { for (cs, pilot) in lineup { ui.label(cs); ui.strong(pilot); ui.end_row(); } }); }); } } }); }); game_data.open &= open; } // ── Ships browser ─────────────────────────────────────────────────────────────── /// The Ships browser — a floating window over the capital ships reconstructed /// from XBG7 part families. Picking a ship assembles its parts and renders the /// whole model in the 3D view. Its own system (keeps `draw_viewer_ui` under the /// parameter limit); a `RequestShipCatalog` event (View menu) opens it and lazily /// kicks off the stage scan, while clicking a row fires `RequestShipRender`. #[cfg(not(target_arch = "wasm32"))] fn draw_ships_ui( mut contexts: EguiContexts, mut ships: ResMut, mut requests: EventReader, mut render: EventWriter, ) { if requests.read().next().is_some() { ships.open = true; } if !ships.open { return; } let ctx = contexts.ctx_mut().clone(); let mut open = true; egui::Window::new("🚀 Ships") .default_width(560.0) .default_height(560.0) .open(&mut open) .show(&ctx, |ui| { if ships.loading { ui.horizontal(|ui| { ui.spinner(); ui.label("Scanning stage models for ships…"); }); ctx.request_repaint(); return; } if !ships.loaded { ui.label("Open a game source first (File ▸ Open…)."); return; } // Faction filter chips. ui.horizontal(|ui| { for (label, val) in [("All", ""), ("ADAN", "ADAN"), ("TCAF", "TCAF"), ("Neutral", "Neutral")] { let sel = ships.faction == val; if ui.selectable_label(sel, label).clicked() { ships.faction = val.to_string(); } } }); ui.horizontal(|ui| { ui.label("🔍"); ui.text_edit_singleline(&mut ships.filter); if !ships.filter.is_empty() && ui.small_button("✖").clicked() { ships.filter.clear(); } }); ui.horizontal(|ui| { ui.checkbox(&mut ships.show_external, "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; }