//! ISO / extracted-directory loading pipeline for the viewer. //! //! Bridges the async `XisoReader` API (and synchronous `GameAssets`) with //! Bevy's ECS via background threads and `std::sync::mpsc` channels polled //! each frame — no blocking on the main thread. //! //! ## Data flow (native) //! //! ```text //! UI click → Event → handle_open_iso / handle_open_dir / handle_file_selected //! └─ std::thread ─→ mpsc::Sender //! ↓ (next frame) //! poll_loader_channel → FileBrowserState / PendingFileBytes //! ↓ //! apply_loaded_texture → TexturePreview (Image + egui TextureId) //! ↓ //! draw_viewer_ui → renders preview panel //! ``` #[cfg(not(target_arch = "wasm32"))] use std::sync::{mpsc, Mutex}; #[cfg(not(target_arch = "wasm32"))] use std::io::Read; #[cfg(not(target_arch = "wasm32"))] use std::path::Path; #[cfg(not(target_arch = "wasm32"))] use std::process::{Child, Command, Stdio}; #[cfg(not(target_arch = "wasm32"))] use bevy::render::render_asset::RenderAssetUsages; #[cfg(not(target_arch = "wasm32"))] use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; use std::path::PathBuf; use bevy::prelude::*; use bevy_egui::egui; use sylpheed_formats::vfs::FileFormat; use crate::ui::FileBrowserState; // ── Events ──────────────────────────────────────────────────────────────────── /// Fired by the "Open ISO disc image…" menu item. #[derive(Event, Default)] pub struct RequestOpenIso; /// Fired by the "Open extracted folder…" menu item. #[derive(Event, Default)] pub struct RequestOpenDir; /// Fired when the user clicks a file in the browser panel. #[derive(Event)] pub struct FileSelected(pub String); // ── Resources (platform-agnostic) ──────────────────────────────────────────── /// Which kind of source is currently open. #[derive(Default, Clone)] pub enum SourceKind { #[default] None, #[cfg(not(target_arch = "wasm32"))] Iso(PathBuf), #[cfg(not(target_arch = "wasm32"))] Directory(PathBuf), } /// State of the currently-open file source (ISO or extracted folder). #[derive(Resource, Default)] pub struct IsoState { /// Short description shown in the status bar (filename or directory path). pub source_label: Option, /// True while a background thread is loading. pub loading: bool, /// The most recent error message (cleared when a new operation starts). pub error: Option, /// Which backend to use when reading individual files. pub source_kind: SourceKind, } /// The texture currently shown in the central panel. #[derive(Resource, Default)] pub struct TexturePreview { /// Bevy handle — kept alive so the GPU texture stays allocated. pub handle: Option>, /// egui texture ID registered via `EguiContexts::add_image`. pub egui_id: Option, pub width: u32, pub height: u32, /// Human-readable format / dimension summary (or error message). pub format_info: String, } /// Info shown for any selected file (texture or not). #[derive(Resource, Default)] pub struct FileInfo { pub name: String, pub size_bytes: usize, pub detected_format: Option, } /// Decoded contents of the currently-selected text file, shown in the central /// panel. `content` is `None` whenever the selection isn't text. #[derive(Resource, Default)] pub struct TextPreview { /// Decoded UTF-8 (any BOM / UTF-16 already resolved), or `None`. pub content: Option, /// Encoding label for the header, e.g. "UTF-16LE (BOM)". pub encoding: String, /// True when the file was larger than `MAX_TEXT_BYTES` and got clipped. pub truncated: bool, } /// Cap on the decoded text we hand to egui — its text layout cost grows /// super-linearly, and disc config files are only a few KiB anyway. const MAX_TEXT_BYTES: usize = 2 * 1024 * 1024; /// One row in the pack browser's entry list. Owned/plain so it crosses the /// loader channel and lives in a resource without borrowing a `PakArchive`. #[derive(Clone)] pub struct PakRow { pub hash: u32, pub comp_size: u32, /// Decompressed payload length, or `comp_size` when the entry wasn't decoded. pub size: usize, /// Inner-format label (`IDXD`, a 4-char tag, `(not decoded)`, …). pub format: String, /// Short identity (`ID=…` / `Name=…`), empty for non-IDXD entries. pub identity: String, /// Recovered original TOC path (e.g. `unit\rou_f001.tbl`) via the name-hash, /// when a known scheme reproduces `hash`. `None` = unresolved (~70% of entries). pub name: Option, /// Parsed IDXD detail for the property table, when the entry is an object. pub detail: Option, /// Decoded presentable payload for the detail pane (subtitle / font / image /// / text). `None` for IDXD (uses `detail`) and un-presentable formats. pub content: PakContent, } /// Presentable, off-thread-decoded content behind a pack entry, shown in the /// browser's detail pane without touching the Bevy texture pipeline. #[derive(Clone, Default)] pub enum PakContent { #[default] None, /// IXUD subtitle / localized-string track. Subtitle(sylpheed_formats::ixud::Subtitle), /// Embedded font: metadata + a pre-rasterized sample line (RGBA8), if the /// font could be rendered off-thread (`None` for collections / CFF that our /// rasterizer declines — metadata still shows). Font { info: sylpheed_formats::FontInfo, sample: Option, }, /// Decoded PNG image (RGBA8), shown via an egui texture. Png(ImageRgba), /// A decoded T8aD 2D texture. T8ad(ImageRgba), /// An LSTA sprite list — inline T8aD frames. Lsta(Vec), /// A RATC bundle — its listed children (T8aD children carry a decoded image). Ratc(Vec), /// Plain-text / XML payload, with its encoding label. Text { text: String, encoding: String }, /// An audio stream (WAV / XMA / raw XMA2) — metadata only; XMA isn't decoded. Audio(sylpheed_formats::AudioInfo), } /// One child in a presented RATC bundle. #[derive(Clone)] pub struct RatcEntry { pub name: String, pub kind: String, pub size: usize, /// Decoded image when the child is a (decodable) T8aD within the pixel budget. pub image: Option, } /// A plain RGBA8 image handed to the UI for an egui texture. #[derive(Clone)] pub struct ImageRgba { pub width: u32, pub height: u32, pub rgba: Vec, } impl ImageRgba { fn from_t8ad(img: sylpheed_formats::T8adImage) -> Self { Self { width: img.width, height: img.height, rgba: img.rgba, } } fn pixels(&self) -> usize { (self.width as usize) * (self.height as usize) } } /// The parsed IDXD detail behind one entry, shown in the master-detail pane. #[derive(Clone)] pub struct PakDetail { pub schema_hash: u32, pub count: u32, /// Explicit (key, value) fields (defaulted fields omitted), owned. pub fields: Vec<(String, String)>, } /// One decoded cubemap face, registered as an egui image. pub struct FaceTex { pub label: &'static str, pub handle: Handle, pub egui_id: egui::TextureId, pub width: u32, pub height: u32, } /// The currently-open world cubemap (`TXCM`), shown as a labelled 6-face grid. #[derive(Resource, Default)] pub struct SkyboxPreview { pub faces: Vec, /// Header summary (dimensions / format). pub info: String, } /// Marker for entities spawned to preview an XBG7 3D model, so they can all be /// despawned when a new file is selected. #[derive(Component)] pub struct PreviewModel; /// The currently-previewed XBG7 3D model. When `active`, the central panel is /// drawn transparent so the real Bevy scene (mesh + orbit camera) shows through. #[derive(Resource, Default)] pub struct ModelPreview { pub active: bool, pub name: String, /// Summary line: sub-meshes / vertices / triangles / texture. pub info: String, } /// The currently-open `.wmv` cutscene, shown in the central panel with a /// transport bar. Registered unconditionally (the decode/audio engine below is /// native-only); on wasm `active` stays false and `.wmv` shows the info panel. #[derive(Resource)] pub struct VideoPreview { /// True while a video is loaded and the player owns the central panel. pub active: bool, pub name: String, pub width: u32, pub height: u32, /// Total length in seconds (from `ffprobe`). pub duration: f32, /// Current playback position in seconds (drives the frame clock + timeline). pub position: f32, pub playing: bool, /// The frame texture — one `Image`, overwritten in place each tick. pub handle: Option>, pub egui_id: Option, /// 0.0..=1.0 audio level (ignored when `!has_audio`). pub volume: f32, pub has_audio: bool, /// Set on drag-release / click / keyboard skip; restarts the streaming /// decoder (and audio) at this position. pub seek_request: Option, /// Set continuously while dragging the timeline; asks the one-shot grabber /// for the frame at this position so scrubbing shows a live preview. pub scrub_request: Option, /// True while the knob is being dragged — display grabbed scrub frames and /// hold the streaming decoder until release. pub scrubbing: bool, } impl Default for VideoPreview { fn default() -> Self { Self { active: false, name: String::new(), width: 0, height: 0, duration: 0.0, position: 0.0, playing: false, handle: None, egui_id: None, volume: 0.8, has_audio: false, seek_request: None, scrub_request: None, scrubbing: false, } } } /// The currently-open IPFB data pack, shown as a master-detail browser. #[derive(Resource, Default)] pub struct PakView { pub loaded: bool, pub name: String, pub rows: Vec, pub selected: Option, pub error: Option, /// egui-side cache: (entry hash, textures) for the shown entry's image(s) — /// one for PNG/T8aD/font-sample, many for LSTA/RATC. pub img_tex: Option<(u32, Vec)>, } /// Total decompressed bytes we're willing to decode when labelling a pack's /// entries — bounds work on a huge `sound.pak`; entries past it show as /// `(not decoded)`. const PAK_DECODE_BUDGET: usize = 64 * 1024 * 1024; /// Skip decoding any single entry whose stored size exceeds this (cheap guard /// against a pathological entry, since `decompress_entry` has no output cap). const PAK_ENTRY_COMP_CAP: u32 = 16 * 1024 * 1024; /// Bounded decoded-frame queue between the ffmpeg reader thread and the main /// thread. Also the back-pressure valve: when we stop draining (paused/behind), /// the queue fills, the reader blocks on `send`, and ffmpeg blocks on its pipe. #[cfg(not(target_arch = "wasm32"))] const VIDEO_FRAME_QUEUE: usize = 8; // ── SystemSet label ─────────────────────────────────────────────────────────── /// `draw_viewer_ui` runs `.after(IsoLoaderSystemSet)` to see the frame's /// final state before rendering. #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub struct IsoLoaderSystemSet; // ── Native-only types ───────────────────────────────────────────────────────── /// Messages sent from background threads back to the Bevy main thread. #[cfg(not(target_arch = "wasm32"))] enum IsoLoaderMsg { FilesListed { source: SourceKind, label: String, files: Vec, }, FileLoaded { path: String, bytes: Vec, }, /// A `.pak` archive assembled + labelled off-thread (`error` set on failure). PakLoaded { name: String, rows: Vec, error: Option, }, /// A `.wmv` materialised to a temp file + probed (+ audio pre-decoded) off /// the main thread; the frame pipe is opened later in `apply_loaded_video`. VideoLoaded(Box), /// User dismissed the file dialog — not an error. Cancelled, Error(String), } /// A probed, temp-materialised video ready for playback. Boxed inside the /// message so the enum stays small. #[cfg(not(target_arch = "wasm32"))] pub struct PreparedVideo { name: String, /// Temp `.wmv` on disk (ffmpeg reads it by path). temp_video: PathBuf, /// Temp stereo WAV of the audio track, or `None` (no audio / decode failed). temp_wav: Option, width: u32, height: u32, fps: f32, duration: f32, } /// Channel resource. `Receiver` is `!Sync`, so we wrap it in `Mutex` /// to satisfy Bevy's `Resource: Send + Sync` bound. #[cfg(not(target_arch = "wasm32"))] #[derive(Resource)] struct IsoChannels { sender: mpsc::Sender, receiver: Mutex>, } #[cfg(not(target_arch = "wasm32"))] impl Default for IsoChannels { fn default() -> Self { let (sender, receiver) = mpsc::channel(); Self { sender, receiver: Mutex::new(receiver), } } } /// One-frame staging buffer: `poll_loader_channel` deposits raw bytes here; /// `apply_loaded_texture` (chained after) consumes them. #[cfg(not(target_arch = "wasm32"))] #[derive(Resource, Default)] struct PendingFileBytes { ready: bool, path: String, bytes: Vec, } /// One-frame staging buffer for a loaded pack; `apply_pak` (chained after) /// consumes it, freeing the previous preview and populating `PakView`. #[cfg(not(target_arch = "wasm32"))] #[derive(Resource, Default)] struct PendingPak { ready: bool, name: String, rows: Vec, error: Option, } /// One-frame staging buffer for a prepared video; `apply_loaded_video` (chained /// after) consumes it, spawns the frame pipe + audio sink, and fills `VideoPreview`. #[cfg(not(target_arch = "wasm32"))] #[derive(Resource, Default)] struct PendingVideo { ready: bool, video: Option>, } /// Native-only playback engine backing the current `VideoPreview`. Held as a /// `NonSend` resource because `rodio::OutputStream` is `!Send`. `None` when no /// video is playing. #[cfg(not(target_arch = "wasm32"))] #[derive(Default)] struct VideoEngine { inner: Option, } #[cfg(not(target_arch = "wasm32"))] struct VideoEngineInner { /// Decoded RGBA frames `(pts_seconds, bytes)` from the ffmpeg reader thread. frames: mpsc::Receiver<(f32, Vec)>, /// The running `ffmpeg … -f rawvideo` child (killed on seek/teardown). child: Child, width: u32, height: u32, fps: f32, temp_video: PathBuf, temp_wav: Option, /// A frame pulled from the queue whose pts is still in the future — shown /// once the clock reaches it (so we never drop or mis-order frames). held: Option<(f32, Vec)>, /// Desired scrub positions → one-shot grabber thread (coalesced to latest). scrub_tx: mpsc::Sender, /// Grabbed scrub frames back from the grabber thread. scrub_frame_rx: mpsc::Receiver>, /// After a commit-seek, keep showing the last grabbed frame until the /// restarted streaming decoder produces its first frame (no black flash). awaiting_seek_frame: bool, // ── audio (present only when a temp WAV was produced) ── /// Kept alive to keep the audio device open; dropping it stops playback. _stream: Option, stream_handle: Option, sink: Option, /// Last volume pushed to the sink, to avoid redundant `set_volume` calls. applied_volume: f32, /// Last `playing` state mirrored to the sink, to detect transitions. was_playing: bool, } // ── Plugin ──────────────────────────────────────────────────────────────────── pub struct IsoLoaderPlugin; impl Plugin for IsoLoaderPlugin { fn build(&self, app: &mut App) { app.add_event::() .add_event::() .add_event::() .init_resource::() .init_resource::() .init_resource::() // Registered unconditionally (even on wasm, where the load systems // below are cfg'd out) so the UI can always read them. .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::(); #[cfg(not(target_arch = "wasm32"))] { app.init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_non_send_resource::() .add_systems( Update, ( handle_open_iso, handle_open_dir, handle_file_selected, poll_loader_channel, apply_loaded_texture, apply_pak, apply_loaded_video, advance_video_playback, ) .chain() .in_set(IsoLoaderSystemSet), ); } } } // ── Systems (native-only) ───────────────────────────────────────────────────── /// Listens for `RequestOpenIso`, opens a native file-picker in a background /// thread, then lists all files in the selected ISO. #[cfg(not(target_arch = "wasm32"))] fn handle_open_iso( mut events: EventReader, mut iso_state: ResMut, channels: Res, ) { let count = events.read().count(); if count == 0 || iso_state.loading { return; } iso_state.loading = true; iso_state.error = None; let sender = channels.sender.clone(); std::thread::spawn(move || { let path = rfd::FileDialog::new() .set_title("Open Xbox 360 Disc Image") .add_filter("Xbox 360 ISO", &["iso", "xiso"]) .pick_file(); let Some(path) = path else { let _ = sender.send(IsoLoaderMsg::Cancelled); return; }; let label = path.display().to_string(); let iso_path = path.clone(); let result = futures::executor::block_on(async move { let mut reader = sylpheed_formats::xiso::open_iso(&path) .await .map_err(|e| e.to_string())?; let files = reader .list_all_files() .await .map_err(|e| e.to_string())?; Ok::<_, String>((iso_path, label, files)) }); match result { Ok((iso_path, label, files)) => { let _ = sender.send(IsoLoaderMsg::FilesListed { source: SourceKind::Iso(iso_path), label, files, }); } Err(e) => { let _ = sender.send(IsoLoaderMsg::Error(e)); } } }); } /// Listens for `RequestOpenDir`, opens a folder picker, lists files via /// `GameAssets` (synchronous). #[cfg(not(target_arch = "wasm32"))] fn handle_open_dir( mut events: EventReader, mut iso_state: ResMut, channels: Res, ) { let count = events.read().count(); if count == 0 || iso_state.loading { return; } iso_state.loading = true; iso_state.error = None; let sender = channels.sender.clone(); std::thread::spawn(move || { let path = rfd::FileDialog::new() .set_title("Open Extracted Game Directory") .pick_folder(); let Some(path) = path else { let _ = sender.send(IsoLoaderMsg::Cancelled); return; }; let label = path.display().to_string(); let assets = sylpheed_formats::vfs::GameAssets::from_directory(&path); match assets.list("") { Ok(files) => { let _ = sender.send(IsoLoaderMsg::FilesListed { source: SourceKind::Directory(path), label, files, }); } Err(e) => { let _ = sender.send(IsoLoaderMsg::Error(e.to_string())); } } }); } /// Listens for `FileSelected`, reads the file from the active source, /// sends bytes back via the channel. #[cfg(not(target_arch = "wasm32"))] fn handle_file_selected( mut events: EventReader, iso_state: Res, channels: Res, ) { for FileSelected(file_path) in events.read() { let file_path = file_path.clone(); let sender = channels.sender.clone(); // `.pak` archives need their index plus sibling `.pNN` segments, and the // decode is heavy — assemble + label off-thread, then hand the UI plain // rows. Everything else falls through to the single-file read below. if file_path.to_ascii_lowercase().ends_with(".pak") { let name = file_path.rsplit('/').next().unwrap_or(&file_path).to_string(); let base = file_path[..file_path.len() - 4].to_string(); // strip ".pak" match &iso_state.source_kind { SourceKind::Iso(iso_path) => { let iso_path = iso_path.clone(); let pak_path = file_path.clone(); std::thread::spawn(move || { let result = futures::executor::block_on(async { let mut reader = sylpheed_formats::xiso::open_iso(&iso_path) .await .map_err(|e| e.to_string())?; let index = reader .read_file(&pak_path) .await .map_err(|e| e.to_string())?; let mut data = Vec::new(); for i in 0..100u32 { match reader.read_file(&format!("{base}.p{i:02}")).await { Ok(mut b) => data.append(&mut b), Err(_) => break, // first gap = end of segments } } if data.is_empty() { return Err("no .pNN data segments found".to_string()); } build_pak_rows(&index, data) }); let (rows, error) = match result { Ok(rows) => (rows, None), Err(e) => (Vec::new(), Some(e)), }; let _ = sender.send(IsoLoaderMsg::PakLoaded { name, rows, error }); }); } SourceKind::Directory(root) => { let root = root.clone(); let pak_path = file_path.clone(); std::thread::spawn(move || { let assets = sylpheed_formats::vfs::GameAssets::from_directory(&root); let result = (|| { let index = assets.read(&pak_path).map_err(|e| e.to_string())?; let mut data = Vec::new(); for i in 0..100u32 { match assets.read(&format!("{base}.p{i:02}")) { Ok(mut b) => data.append(&mut b), Err(_) => break, } } if data.is_empty() { return Err("no .pNN data segments found".to_string()); } build_pak_rows(&index, data) })(); let (rows, error) = match result { Ok(rows) => (rows, None), Err(e) => (Vec::new(), Some(e)), }; let _ = sender.send(IsoLoaderMsg::PakLoaded { name, rows, error }); }); } SourceKind::None => {} } continue; } // `.wmv` cutscenes: read the bytes, spill to a temp file (ffmpeg needs a // real path), probe dimensions/duration, and pre-decode the audio track // to a temp WAV — all off-thread. The video frame pipe is opened later. if file_path.to_ascii_lowercase().ends_with(".wmv") { let name = file_path.rsplit('/').next().unwrap_or(&file_path).to_string(); match &iso_state.source_kind { SourceKind::Iso(iso_path) => { let iso_path = iso_path.clone(); let vid_path = file_path.clone(); std::thread::spawn(move || { let result = futures::executor::block_on(async { let mut reader = sylpheed_formats::xiso::open_iso(&iso_path) .await .map_err(|e| e.to_string())?; reader.read_file(&vid_path).await.map_err(|e| e.to_string()) }); let msg = match result.and_then(|bytes| prepare_video(&name, bytes)) { Ok(prepared) => IsoLoaderMsg::VideoLoaded(Box::new(prepared)), Err(e) => IsoLoaderMsg::Error(e), }; let _ = sender.send(msg); }); } SourceKind::Directory(root) => { let root = root.clone(); let vid_path = file_path.clone(); std::thread::spawn(move || { let assets = sylpheed_formats::vfs::GameAssets::from_directory(&root); let msg = match assets .read(&vid_path) .map_err(|e| e.to_string()) .and_then(|bytes| prepare_video(&name, bytes)) { Ok(prepared) => IsoLoaderMsg::VideoLoaded(Box::new(prepared)), Err(e) => IsoLoaderMsg::Error(e), }; let _ = sender.send(msg); }); } SourceKind::None => {} } continue; } match &iso_state.source_kind { SourceKind::Iso(iso_path) => { let iso_path = iso_path.clone(); std::thread::spawn(move || { let result = futures::executor::block_on(async { let mut reader = sylpheed_formats::xiso::open_iso(&iso_path) .await .map_err(|e| e.to_string())?; let bytes = reader .read_file(&file_path) .await .map_err(|e| e.to_string())?; Ok::<_, String>((file_path, bytes)) }); match result { Ok((path, bytes)) => { let _ = sender .send(IsoLoaderMsg::FileLoaded { path, bytes }); } Err(e) => { let _ = sender.send(IsoLoaderMsg::Error(e)); } } }); } SourceKind::Directory(root) => { let assets = sylpheed_formats::vfs::GameAssets::from_directory(root); match assets.read(&file_path) { Ok(bytes) => { let _ = sender.send(IsoLoaderMsg::FileLoaded { path: file_path, bytes, }); } Err(e) => { let _ = sender.send(IsoLoaderMsg::Error(e.to_string())); } } } SourceKind::None => {} } } } /// Assemble a `PakArchive` from its index + concatenated segment data, then /// build one `PakRow` per entry (decompressing + IDXD-parsing under the decode /// budget). Runs on the loader thread; returns owned rows for the UI. #[cfg(not(target_arch = "wasm32"))] fn build_pak_rows(index: &[u8], data: Vec) -> Result, String> { use sylpheed_formats::pak::inner_format_label; use sylpheed_formats::{IdxdObject, PakArchive}; let arc = PakArchive::from_parts(index, data).map_err(|e| e.to_string())?; let mut rows = Vec::with_capacity(arc.entries().len()); let mut decoded_budget: usize = 0; for e in arc.entries() { // Skip decoding oversized or over-budget entries — bounds a huge pack. if e.comp_size > PAK_ENTRY_COMP_CAP || decoded_budget > PAK_DECODE_BUDGET { rows.push(PakRow { hash: e.name_hash, comp_size: e.comp_size, size: e.comp_size as usize, format: "(not decoded)".into(), identity: String::new(), name: None, detail: None, content: PakContent::None, }); continue; } match arc.read(e) { Ok(payload) => { decoded_budget += payload.len(); let format = inner_format_label(&payload); let (identity, name, detail) = if IdxdObject::is_idxd(&payload) { match IdxdObject::parse(&payload) { Ok(obj) => { let fields = obj .resolved_fields() .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(); ( obj.identity(), obj.recover_toc_path(e.name_hash), Some(PakDetail { schema_hash: obj.schema_hash, count: obj.count, fields, }), ) } Err(_) => (String::new(), None, None), } } else { (String::new(), None, None) }; // Presentable content for non-IDXD entries (subtitle/font/png/text). let content = if detail.is_some() { PakContent::None } else { classify_content(&payload) }; rows.push(PakRow { hash: e.name_hash, comp_size: e.comp_size, size: payload.len(), format, identity, name, detail, content, }); } Err(err) => rows.push(PakRow { hash: e.name_hash, comp_size: e.comp_size, size: e.comp_size as usize, format: "(read error)".into(), identity: err.to_string(), name: None, detail: None, content: PakContent::None, }), } } Ok(rows) } /// Cap on decoded text we retain per pack entry (xml/text preview). #[cfg(not(target_arch = "wasm32"))] const PAK_TEXT_CAP: usize = 512 * 1024; /// Cap on decoded child-image texels per LSTA/RATC entry (~8 M texels = 32 MB /// RGBA). Further children past it are listed but not decoded to an image. #[cfg(not(target_arch = "wasm32"))] const PAK_IMAGE_TEXEL_BUDGET: usize = 8 * 1024 * 1024; /// Classify a (non-IDXD) decompressed entry into presentable content: subtitle /// track, embedded font, PNG image, or plain text/XML. Runs off-thread. #[cfg(not(target_arch = "wasm32"))] fn classify_content(payload: &[u8]) -> PakContent { use sylpheed_formats::{font, ixud, vfs}; if ixud::is_ixud(payload) { if let Some(sub) = ixud::parse(payload) { return PakContent::Subtitle(sub); } } if font::is_font(payload) { if let Some(info) = font::parse_info(payload) { // Rasterize a sample line here (off-thread) for single-face fonts — // never touch egui's global font system on the main thread. let sample = if info.faces <= 1 && info.kind != "TrueType Collection" { render_font_sample(payload, "The quick brown fox — 0123456789 !?&", 34.0) } else { None }; return PakContent::Font { info, sample }; } } if payload.starts_with(&[0x89, 0x50, 0x4E, 0x47]) { if let Ok(img) = image::load_from_memory_with_format(payload, image::ImageFormat::Png) { let rgba = img.to_rgba8(); let (width, height) = rgba.dimensions(); return PakContent::Png(ImageRgba { width, height, rgba: rgba.into_raw(), }); } } if sylpheed_formats::t8ad::is_t8ad(payload) { if let Some(img) = sylpheed_formats::t8ad::parse(payload) { return PakContent::T8ad(ImageRgba::from_t8ad(img)); } } if sylpheed_formats::lsta::is_lsta(payload) { if let Some(frames) = sylpheed_formats::lsta::parse(payload) { let mut budget = PAK_IMAGE_TEXEL_BUDGET; let out: Vec = frames .into_iter() .map(ImageRgba::from_t8ad) .take_while(|f| { budget = budget.saturating_sub(f.pixels()); budget > 0 }) .collect(); if !out.is_empty() { return PakContent::Lsta(out); } } } if sylpheed_formats::ratc::is_ratc(payload) { if let Some(children) = sylpheed_formats::ratc::parse(payload) { let mut budget = PAK_IMAGE_TEXEL_BUDGET; let entries: Vec = children .into_iter() .map(|c| { // Decode T8aD children up to the pixel budget; list the rest. let mut image = None; if c.kind == "T8aD" && budget > 0 { if let Some(img) = payload .get(c.offset..c.offset + c.size) .and_then(sylpheed_formats::t8ad::parse) .map(ImageRgba::from_t8ad) { budget = budget.saturating_sub(img.pixels()); image = Some(img); } } RatcEntry { name: c.name, kind: c.kind, size: c.size, image, } }) .collect(); if !entries.is_empty() { return PakContent::Ratc(entries); } } } if payload.starts_with(b" PAK_TEXT_CAP { let end = text .char_indices() .take_while(|(i, _)| *i < PAK_TEXT_CAP) .last() .map(|(i, c)| i + c.len_utf8()) .unwrap_or(0); text.truncate(end); } return PakContent::Text { text, encoding: encoding.to_string(), }; } // Last resort: a self-describing WAV/XMA header, or a header-less high- // entropy blob (the game's raw XMA2 sound-bank streams). let audio = sylpheed_formats::AudioInfo::probe(payload); if audio.codec != sylpheed_formats::AudioCodec::Unknown { return PakContent::Audio(audio); } PakContent::None } /// Rasterize a sample line with the embedded font into a white-on-transparent /// RGBA image. Fully off-thread and panic-free: `ab_glyph` returns `None` for /// unusable fonts/glyphs rather than unwrapping (unlike egui's global fonts). #[cfg(not(target_arch = "wasm32"))] fn render_font_sample(bytes: &[u8], text: &str, px: f32) -> Option { use ab_glyph::{point, Font, FontRef, ScaleFont}; let font = FontRef::try_from_slice(bytes).ok()?; let scaled = font.as_scaled(px); let (ascent, descent) = (scaled.ascent(), scaled.descent()); let pad = 2.0; // Lay glyphs left-to-right on a single baseline. let mut caret = pad; let mut glyphs = Vec::new(); for ch in text.chars() { let id = font.glyph_id(ch); glyphs.push(id.with_scale_and_position(px, point(caret, pad + ascent))); caret += scaled.h_advance(id); } let width = (caret + pad).ceil().max(1.0) as u32; let height = (pad * 2.0 + ascent - descent).ceil().max(1.0) as u32; if width > 8192 || height > 512 { return None; // sanity bound } let mut rgba = vec![0u8; (width as usize) * (height as usize) * 4]; let mut drew_any = false; for g in glyphs { if let Some(outline) = font.outline_glyph(g) { let bb = outline.px_bounds(); outline.draw(|gx, gy, cov| { let x = bb.min.x as i32 + gx as i32; let y = bb.min.y as i32 + gy as i32; if x >= 0 && y >= 0 && (x as u32) < width && (y as u32) < height { let i = ((y as u32 * width + x as u32) * 4) as usize; let a = (cov * 255.0) as u8; rgba[i] = 255; rgba[i + 1] = 255; rgba[i + 2] = 255; rgba[i + 3] = rgba[i + 3].max(a); drew_any = true; } }); } } drew_any.then_some(ImageRgba { width, height, rgba, }) } // ── Video preparation (loader thread) ───────────────────────────────────────── /// Make a filesystem-safe stem for temp files from a leaf name. #[cfg(not(target_arch = "wasm32"))] fn sanitize_stem(name: &str) -> String { name.chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) .collect() } /// Spill the video bytes to a temp file, probe it, and pre-decode its audio. /// Runs on the loader thread. Maps a missing `ffmpeg`/`ffprobe` to a friendly /// error so the UI degrades gracefully instead of panicking. #[cfg(not(target_arch = "wasm32"))] fn prepare_video(name: &str, bytes: Vec) -> Result { let dir = std::env::temp_dir(); let stem = format!("sylph_vid_{}_{}", std::process::id(), sanitize_stem(name)); let temp_video = dir.join(format!("{stem}.wmv")); std::fs::write(&temp_video, &bytes) .map_err(|e| format!("writing temp video: {e}"))?; let (width, height, fps, duration) = ffprobe_video(&temp_video)?; // Audio is best-effort: decode the track to a temp stereo WAV so rodio can // play it with volume/seek. Failure (no track / codec issue) → silent video. let temp_wav = if ffprobe_has_audio(&temp_video) { let wav = dir.join(format!("{stem}.wav")); match decode_audio_wav(&temp_video, &wav) { Ok(()) => Some(wav), Err(e) => { warn!("audio pre-decode failed ({name}): {e}"); None } } } else { None }; Ok(PreparedVideo { name: name.to_string(), temp_video, temp_wav, width, height, fps, duration, }) } /// Run a command to completion, mapping a not-found spawn error to a friendly /// "install ffmpeg" message and a non-zero exit to its stderr. #[cfg(not(target_arch = "wasm32"))] fn run_tool(cmd: &mut Command, tool: &str) -> Result { let out = cmd.output().map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { format!("`{tool}` not found in PATH — install FFmpeg to play videos") } else { format!("running `{tool}`: {e}") } })?; if !out.status.success() { return Err(format!( "`{tool}` failed: {}", String::from_utf8_lossy(&out.stderr).trim() )); } Ok(out) } /// Probe width/height/fps/duration of the first video stream via `ffprobe`. #[cfg(not(target_arch = "wasm32"))] fn ffprobe_video(path: &Path) -> Result<(u32, u32, f32, f32), String> { let out = run_tool( Command::new("ffprobe") .arg("-v") .arg("error") .arg("-select_streams") .arg("v:0") .arg("-show_entries") .arg("stream=width,height,avg_frame_rate:format=duration") .arg("-of") .arg("default=noprint_wrappers=1") .arg(path), "ffprobe", )?; let text = String::from_utf8_lossy(&out.stdout); let (mut w, mut h, mut fps, mut dur) = (0u32, 0u32, 30.0f32, 0.0f32); for line in text.lines() { let Some((k, v)) = line.split_once('=') else { continue }; match k.trim() { "width" => w = v.trim().parse().unwrap_or(0), "height" => h = v.trim().parse().unwrap_or(0), "avg_frame_rate" => { // "30/1" (or "0/0" for VFR/unknown → keep the 30 fps default). if let Some((n, d)) = v.trim().split_once('/') { let (n, d) = (n.parse::().unwrap_or(0.0), d.parse::().unwrap_or(0.0)); if n > 0.0 && d > 0.0 { fps = n / d; } } } "duration" => dur = v.trim().parse().unwrap_or(0.0), _ => {} } } if w == 0 || h == 0 { return Err("ffprobe: no video stream / zero dimensions".to_string()); } Ok((w, h, fps, dur)) } /// True if the file has at least one audio stream. #[cfg(not(target_arch = "wasm32"))] fn ffprobe_has_audio(path: &Path) -> bool { Command::new("ffprobe") .arg("-v") .arg("error") .arg("-select_streams") .arg("a") .arg("-show_entries") .arg("stream=index") .arg("-of") .arg("csv=p=0") .arg(path) .output() .map(|o| o.status.success() && !o.stdout.iter().all(u8::is_ascii_whitespace)) .unwrap_or(false) } /// Decode the audio track to a temp stereo 48 kHz WAV (downmixing surround). #[cfg(not(target_arch = "wasm32"))] fn decode_audio_wav(video: &Path, wav: &Path) -> Result<(), String> { run_tool( Command::new("ffmpeg") .arg("-v") .arg("error") .arg("-y") .arg("-i") .arg(video) .arg("-vn") .arg("-ac") .arg("2") .arg("-ar") .arg("48000") .arg(wav), "ffmpeg", ) .map(|_| ()) } // ── Video decode + audio (main thread) ──────────────────────────────────────── /// Spawn an `ffmpeg` process decoding to raw RGBA on stdout, plus a reader /// thread that chunks it into frames and forwards `(pts, bytes)` over a bounded /// channel. Optional `-ss start` seeks the input; output pts re-base to 0, so we /// add `start` back to each frame's timestamp. #[cfg(not(target_arch = "wasm32"))] fn spawn_video_decoder( path: &Path, w: u32, h: u32, fps: f32, start: f32, ) -> Result<(Child, mpsc::Receiver<(f32, Vec)>), String> { let mut cmd = Command::new("ffmpeg"); cmd.arg("-v").arg("error"); if start > 0.0 { cmd.arg("-ss").arg(format!("{start:.3}")); } cmd.arg("-i") .arg(path) .arg("-f") .arg("rawvideo") .arg("-pix_fmt") .arg("rgba") .arg("-vsync") .arg("0") .arg("pipe:1") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()); let mut child = cmd.spawn().map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { "`ffmpeg` not found in PATH — install FFmpeg to play videos".to_string() } else { format!("spawning ffmpeg: {e}") } })?; let mut stdout = child.stdout.take().expect("piped stdout"); let frame_len = (w as usize) * (h as usize) * 4; // Bounded: when the main thread stops draining, `send` blocks and ffmpeg // back-pressures on its pipe (natural pause / flow control). let (tx, rx) = mpsc::sync_channel::<(f32, Vec)>(VIDEO_FRAME_QUEUE); std::thread::spawn(move || { let mut idx: u64 = 0; loop { let mut buf = vec![0u8; frame_len]; if stdout.read_exact(&mut buf).is_err() { break; // EOF or the child was killed } let pts = start + idx as f32 / fps.max(1.0); idx += 1; if tx.send((pts, buf)).is_err() { break; // receiver dropped (video closed / seeking) } } }); Ok((child, rx)) } /// Build a rodio `Sink` playing `wav` from `start` seconds, at `volume`, paused /// unless `playing`. Returns `None` on any audio failure (video still plays). #[cfg(not(target_arch = "wasm32"))] fn build_audio_sink( handle: &rodio::OutputStreamHandle, wav: &Path, start: f32, volume: f32, playing: bool, ) -> Option { use rodio::Source; let file = std::fs::File::open(wav).ok()?; let decoder = rodio::Decoder::new(std::io::BufReader::new(file)).ok()?; let sink = rodio::Sink::try_new(handle).ok()?; if start > 0.0 { sink.append(decoder.skip_duration(std::time::Duration::from_secs_f32(start))); } else { sink.append(decoder); } sink.set_volume(volume); if !playing { sink.pause(); } Some(sink) } /// Extract a single RGBA frame at `t` seconds via a fast one-shot ffmpeg seek. /// Much cheaper than restarting the streaming decoder — used for live scrubbing. #[cfg(not(target_arch = "wasm32"))] fn grab_one_frame(path: &Path, t: f32, frame_len: usize) -> Option> { let mut child = Command::new("ffmpeg") .arg("-v") .arg("error") .arg("-ss") .arg(format!("{t:.3}")) .arg("-i") .arg(path) .arg("-frames:v") .arg("1") .arg("-f") .arg("rawvideo") .arg("-pix_fmt") .arg("rgba") .arg("pipe:1") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() .ok()?; let mut out = child.stdout.take()?; let mut buf = vec![0u8; frame_len]; let ok = out.read_exact(&mut buf).is_ok(); let _ = child.wait(); ok.then_some(buf) } /// The scrub grabber: waits for requested positions, **coalesces to the most /// recent** (so fast dragging skips stale targets), grabs that frame, and sends /// it back. One ffmpeg at a time; exits when the request channel closes. #[cfg(not(target_arch = "wasm32"))] fn scrub_worker( rx: mpsc::Receiver, tx: mpsc::Sender>, path: PathBuf, w: u32, h: u32, ) { let frame_len = (w as usize) * (h as usize) * 4; while let Ok(mut t) = rx.recv() { while let Ok(newer) = rx.try_recv() { t = newer; // jump to the latest requested position } if let Some(frame) = grab_one_frame(&path, t, frame_len) { if tx.send(frame).is_err() { break; // main side gone } } } } /// Polls the mpsc channel, updating `IsoState`, `FileBrowserState`, and /// `PendingFileBytes` as messages arrive. #[cfg(not(target_arch = "wasm32"))] fn poll_loader_channel( channels: Res, mut iso_state: ResMut, mut browser: ResMut, mut pending: ResMut, mut pending_pak: ResMut, mut pending_video: ResMut, ) { let receiver = channels.receiver.lock().unwrap(); loop { use std::sync::mpsc::TryRecvError; match receiver.try_recv() { Ok(IsoLoaderMsg::FilesListed { source, label, files, }) => { iso_state.loading = false; iso_state.error = None; iso_state.source_label = Some(label); iso_state.source_kind = source; browser.files = files; browser.selected = None; browser.filter.clear(); info!("Loaded {} files", browser.files.len()); } Ok(IsoLoaderMsg::FileLoaded { path, bytes }) => { pending.path = path; pending.bytes = bytes; pending.ready = true; browser.loading = false; } Ok(IsoLoaderMsg::PakLoaded { name, rows, error }) => { pending_pak.name = name; pending_pak.rows = rows; pending_pak.error = error; pending_pak.ready = true; browser.loading = false; } Ok(IsoLoaderMsg::VideoLoaded(prepared)) => { pending_video.video = Some(prepared); pending_video.ready = true; browser.loading = false; } Ok(IsoLoaderMsg::Cancelled) => { iso_state.loading = false; browser.loading = false; } Ok(IsoLoaderMsg::Error(msg)) => { error!("ISO loader: {}", msg); iso_state.loading = false; iso_state.error = Some(msg); browser.loading = false; } Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break, } } } /// Free the current texture preview's GPU image + egui slot and reset it. #[cfg(not(target_arch = "wasm32"))] fn free_texture( preview: &mut TexturePreview, images: &mut Assets, contexts: &mut bevy_egui::EguiContexts<'_, '_>, ) { if let Some(ref handle) = preview.handle { contexts.remove_image(handle); images.remove(handle.id()); } *preview = TexturePreview::default(); } /// Free the current skybox preview's per-face GPU images + egui slots and reset it. #[cfg(not(target_arch = "wasm32"))] fn free_skybox( skybox: &mut SkyboxPreview, images: &mut Assets, contexts: &mut bevy_egui::EguiContexts<'_, '_>, ) { for f in &skybox.faces { contexts.remove_image(&f.handle); images.remove(f.handle.id()); } *skybox = SkyboxPreview::default(); } /// Stop and tear down any playing video: kill ffmpeg, drop the audio sink + /// output stream, release the frame texture, and delete the temp files. #[cfg(not(target_arch = "wasm32"))] fn free_video( video: &mut VideoPreview, engine: &mut VideoEngine, images: &mut Assets, contexts: &mut bevy_egui::EguiContexts<'_, '_>, ) { if let Some(ref handle) = video.handle { contexts.remove_image(handle); images.remove(handle.id()); } if let Some(inner) = engine.inner.take() { // Drop audio first (stops the device), then kill ffmpeg; dropping the // receiver unblocks the reader thread's `send` so it exits. drop(inner.sink); drop(inner._stream); let mut child = inner.child; let _ = child.kill(); let _ = child.wait(); drop(inner.frames); let _ = std::fs::remove_file(&inner.temp_video); if let Some(w) = &inner.temp_wav { let _ = std::fs::remove_file(w); } } *video = VideoPreview::default(); } /// Build Bevy meshes from a decoded [`Xbg7Model`], texture them with the model's /// albedo (`_col`) map, spawn them into the scene tagged [`PreviewModel`], and /// frame the orbit camera on the combined bounding box. #[cfg(not(target_arch = "wasm32"))] #[allow(clippy::too_many_arguments)] fn spawn_model_preview( model: &sylpheed_formats::mesh::Xbg7Model, xpr_bytes: &[u8], commands: &mut Commands, meshes: &mut Assets, materials: &mut Assets, images: &mut Assets, model_preview: &mut ModelPreview, orbit: &mut Query<&mut crate::camera::OrbitCamera>, ) { use bevy::render::mesh::{Indices, PrimitiveTopology}; use sylpheed_formats::texture::X360Texture; // ── Albedo texture: prefer the `_col` map, else the first texture. ── let names = X360Texture::texture_names(xpr_bytes); let col_idx = names .iter() .position(|n| n.ends_with("_col")) .or(if names.is_empty() { None } else { Some(0) }); let (base_color_texture, tex_label) = match col_idx { Some(i) => match X360Texture::from_xpr2_index(xpr_bytes, i) .ok() .and_then(|t| crate::asset_loader::x360_texture_to_bevy_image(t).ok()) { Some(img) => (Some(images.add(img)), names[i].clone()), None => (None, "none".to_string()), }, None => (None, "none".to_string()), }; let material = materials.add(StandardMaterial { base_color: Color::srgb(0.8, 0.8, 0.82), base_color_texture, perceptual_roughness: 0.7, metallic: 0.1, // Xbox winding / our handedness may disagree — show both sides so a // model is never invisible from the "back". cull_mode: None, double_sided: true, ..default() }); // ── Combined bounding box for camera framing. ── let mut lo = Vec3::splat(f32::MAX); let mut hi = Vec3::splat(f32::MIN); // Parent entity so the whole model can be despawned / transformed as one. let root = commands .spawn(( PreviewModel, Transform::default(), Visibility::default(), Name::new(model.name.clone()), )) .id(); let mut total_v = 0usize; let mut total_t = 0usize; for sub in &model.meshes { if sub.positions.is_empty() || sub.indices.is_empty() { continue; } total_v += sub.positions.len(); total_t += sub.indices.len() / 3; for p in &sub.positions { lo = lo.min(Vec3::from_array(*p)); hi = hi.max(Vec3::from_array(*p)); } let positions: Vec<[f32; 3]> = sub.positions.clone(); let uvs: Vec<[f32; 2]> = if sub.uvs.len() == sub.positions.len() { sub.uvs.clone() } else { vec![[0.0, 0.0]; sub.positions.len()] }; // Prefer the model's own normals; fall back to computed smooth normals. let normals = if sub.normals.len() == sub.positions.len() { sub.normals.clone() } else { compute_smooth_normals(&positions, &sub.indices) }; let mut mesh = Mesh::new( PrimitiveTopology::TriangleList, RenderAssetUsages::default(), ); mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals); mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs); mesh.insert_indices(Indices::U32(sub.indices.clone())); let child = commands .spawn(( PreviewModel, Mesh3d(meshes.add(mesh)), MeshMaterial3d(material.clone()), Transform::default(), )) .id(); commands.entity(root).add_child(child); } // ── Frame the orbit camera on the model. ── let center = if lo.x <= hi.x { (lo + hi) * 0.5 } else { Vec3::ZERO }; let extent = if lo.x <= hi.x { (hi - lo).length().max(0.001) } else { 5.0 }; if let Ok(mut cam) = orbit.get_single_mut() { cam.focus = center; cam.radius = extent * 1.4; cam.min_radius = (extent * 0.02).max(0.02); cam.max_radius = (extent * 20.0).max(50.0); } model_preview.active = true; model_preview.name = model.name.clone(); model_preview.info = format!( "XBG7 model · {} sub-mesh(es) · {} verts · {} tris · albedo: {}", model.meshes.len(), total_v, total_t, tex_label, ); } /// Spawn every decoded sub-model of a **stage** container as a side-by-side row /// (a "cast sheet"): the stage's enemy / prop models are separate objects with no /// recovered scene transforms, so each is recentred on its own origin and laid /// out along +X, spaced by its width. The orbit camera frames the whole row. #[cfg(not(target_arch = "wasm32"))] #[allow(clippy::too_many_arguments)] fn spawn_stage_models( models: &[sylpheed_formats::mesh::Xbg7Model], xpr_bytes: &[u8], commands: &mut Commands, meshes: &mut Assets, materials: &mut Assets, images: &mut Assets, model_preview: &mut ModelPreview, orbit: &mut Query<&mut crate::camera::OrbitCamera>, ) { use bevy::render::mesh::{Indices, PrimitiveTopology}; use sylpheed_formats::texture::X360Texture; // Per-sub-model albedo: match each model's name to its `_col` texture // (e.g. `e003` → `e003_bdy_col`, `e005` → `e005_01_col`). Decode each once // and cache by texture index; fall back to a neutral tint when no `_col` // channel matches. Colours are the same "unverified" item as the 2D textures. let tex_names = X360Texture::texture_names(xpr_bytes); let neutral = materials.add(StandardMaterial { base_color: Color::srgb(0.72, 0.74, 0.78), perceptual_roughness: 0.65, metallic: 0.1, cull_mode: None, double_sided: true, ..default() }); let mut tex_cache: std::collections::HashMap> = std::collections::HashMap::new(); let material_for = |model_name: &str, materials: &mut Assets, images: &mut Assets, cache: &mut std::collections::HashMap>| -> Handle { // Core name = strip leading `_`/`rou_` and trailing `_l` LOD suffix. let core = model_name .trim_start_matches('_') .trim_start_matches("rou_") .trim_end_matches("_l"); let idx = tex_names.iter().position(|n| { n.ends_with("_col") && (n.starts_with(core) || n.contains(core)) }); let Some(i) = idx else { return neutral.clone() }; if let Some(h) = cache.get(&i) { return h.clone(); } let handle = X360Texture::from_xpr2_index(xpr_bytes, i) .ok() .and_then(|t| crate::asset_loader::x360_texture_to_bevy_image(t).ok()) .map(|img| { materials.add(StandardMaterial { base_color: Color::WHITE, base_color_texture: Some(images.add(img)), perceptual_roughness: 0.7, metallic: 0.1, cull_mode: None, double_sided: true, ..default() }) }) .unwrap_or_else(|| neutral.clone()); cache.insert(i, handle.clone()); handle }; let root = commands .spawn(( PreviewModel, Transform::default(), Visibility::default(), Name::new("stage"), )) .id(); // A stage holds up to ~450 sub-models at wildly different true scales (a // 1-unit prop next to a 3000-unit structure) with no recovered scene // transforms. Laying them end-to-end made a mile-wide strip the camera // couldn't escape. Instead present a **thumbnail grid**: each model is // recentred and uniformly scaled to a fixed cell, so all are equally visible // and browsable regardless of native size. Grid spans the XY plane. const CELL: f32 = 10.0; // target on-screen size of each thumbnail const GAP: f32 = 4.0; let pitch = CELL + GAP; // Extent above which a sub-model is a skybox plane (300 k-unit cloud quad) or // a stray-vertex mis-anchor rather than a real object — dropped from the grid. const MAX_EXTENT: f32 = 20_000.0; let mut drawn: Vec<&sylpheed_formats::mesh::Xbg7Model> = Vec::new(); for model in models { let mut lo = Vec3::splat(f32::MAX); let mut hi = Vec3::splat(f32::MIN); let mut has_geo = false; for sub in &model.meshes { if sub.positions.is_empty() || sub.indices.is_empty() { continue; } has_geo = true; for p in &sub.positions { lo = lo.min(Vec3::from_array(*p)); hi = hi.max(Vec3::from_array(*p)); } } if has_geo && (hi - lo).max_element() <= MAX_EXTENT { drawn.push(model); } } let cols = (drawn.len() as f32).sqrt().ceil().max(1.0) as usize; let mut total_v = 0usize; let mut total_t = 0usize; for (i, model) in drawn.iter().enumerate() { // Per-model bbox → recentre + uniform scale to the cell. let mut lo = Vec3::splat(f32::MAX); let mut hi = Vec3::splat(f32::MIN); for sub in &model.meshes { for p in &sub.positions { lo = lo.min(Vec3::from_array(*p)); hi = hi.max(Vec3::from_array(*p)); } } if lo.x > hi.x { continue; } let center = (lo + hi) * 0.5; let extent = (hi - lo).max_element().max(1e-3); let scale = CELL / extent; // Grid cell centre (row-major, growing down in −Y). let col = i % cols; let row = i / cols; let cell_pos = Vec3::new(col as f32 * pitch, -(row as f32) * pitch, 0.0); // One entity per model carries the placement + normalising scale; its // sub-meshes are recentred to the model's own origin underneath it. let model_root = commands .spawn(( PreviewModel, Transform::from_translation(cell_pos).with_scale(Vec3::splat(scale)), Visibility::default(), Name::new(model.name.clone()), )) .id(); commands.entity(root).add_child(model_root); let material = material_for(&model.name, materials, images, &mut tex_cache); for sub in &model.meshes { if sub.positions.is_empty() || sub.indices.is_empty() { continue; } total_v += sub.positions.len(); total_t += sub.indices.len() / 3; let positions: Vec<[f32; 3]> = sub.positions.iter().map(|p| { [p[0] - center.x, p[1] - center.y, p[2] - center.z] }).collect(); let uvs: Vec<[f32; 2]> = if sub.uvs.len() == sub.positions.len() { sub.uvs.clone() } else { vec![[0.0, 0.0]; sub.positions.len()] }; let normals = if sub.normals.len() == sub.positions.len() { sub.normals.clone() } else { compute_smooth_normals(&positions, &sub.indices) }; let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::default()); mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals); mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs); mesh.insert_indices(Indices::U32(sub.indices.clone())); let child = commands .spawn(( PreviewModel, Mesh3d(meshes.add(mesh)), MeshMaterial3d(material.clone()), Transform::default(), )) .id(); commands.entity(model_root).add_child(child); } } // Frame the whole grid. let rows = drawn.len().div_ceil(cols); let grid_w = cols as f32 * pitch; let grid_h = rows as f32 * pitch; let center = Vec3::new(grid_w * 0.5 - pitch * 0.5, -(grid_h * 0.5 - pitch * 0.5), 0.0); let diag = (grid_w * grid_w + grid_h * grid_h).sqrt().max(CELL); if let Ok(mut cam) = orbit.get_single_mut() { cam.focus = center; cam.radius = diag * 0.75; cam.min_radius = CELL * 0.05; cam.max_radius = diag * 3.0; } model_preview.active = true; model_preview.name = "stage".to_string(); model_preview.info = format!( "Stage container · {} sub-models (thumbnail grid, each scaled to fit) · \ {} verts · {} tris (content-anchored; hero bodies with quantized layout \ are skipped)", drawn.len(), total_v, total_t, ); } /// Per-vertex smooth normals = normalized sum of incident face normals. #[cfg(not(target_arch = "wasm32"))] fn compute_smooth_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> { let mut acc = vec![Vec3::ZERO; positions.len()]; for tri in indices.chunks_exact(3) { let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize); if a >= positions.len() || b >= positions.len() || c >= positions.len() { continue; } let pa = Vec3::from_array(positions[a]); let pb = Vec3::from_array(positions[b]); let pc = Vec3::from_array(positions[c]); let n = (pb - pa).cross(pc - pa); acc[a] += n; acc[b] += n; acc[c] += n; } acc.into_iter() .map(|n| n.normalize_or_zero().to_array()) .map(|n| if n == [0.0, 0.0, 0.0] { [0.0, 1.0, 0.0] } else { n }) .collect() } /// Converts pending raw bytes into a Bevy `Image`, registers it with egui, /// and populates `TexturePreview` / `SkyboxPreview` / `FileInfo`. #[cfg(not(target_arch = "wasm32"))] fn apply_loaded_texture( mut pending: ResMut, mut preview: ResMut, mut text_preview: ResMut, mut pak_view: ResMut, mut skybox: ResMut, mut video: ResMut, mut engine: NonSendMut, mut file_info: ResMut, mut images: ResMut>, mut contexts: bevy_egui::EguiContexts, mut commands: Commands, mut meshes: ResMut>, mut materials: ResMut>, mut model_preview: ResMut, old_models: Query>, mut orbit: Query<&mut crate::camera::OrbitCamera>, ) { if !pending.ready { return; } pending.ready = false; let bytes = std::mem::take(&mut pending.bytes); let path = std::mem::take(&mut pending.path); let fmt = sylpheed_formats::vfs::identify_format(&bytes); // Always free the previous previews (GPU memory + egui slots) so exactly one // viewer is active per selection. free_texture(&mut preview, &mut images, &mut contexts); free_skybox(&mut skybox, &mut images, &mut contexts); free_video(&mut video, &mut engine, &mut images, &mut contexts); *text_preview = TextPreview::default(); *pak_view = PakView::default(); // Despawn any previously-previewed 3D model. for e in old_models.iter() { commands.entity(e).despawn_recursive(); } *model_preview = ModelPreview::default(); // Populate FileInfo for the status / info panel. file_info.name = path .split('/') .next_back() .unwrap_or(&path) .to_string(); file_info.size_bytes = bytes.len(); file_info.detected_format = Some(fmt); if fmt == FileFormat::Text { let (mut text, encoding) = sylpheed_formats::vfs::decode_text(&bytes); let truncated = text.len() > MAX_TEXT_BYTES; if truncated { // Cut on a char boundary so we never split a codepoint. let end = text .char_indices() .take_while(|(i, _)| *i < MAX_TEXT_BYTES) .last() .map(|(i, c)| i + c.len_utf8()) .unwrap_or(0); text.truncate(end); } text_preview.content = Some(text); text_preview.encoding = encoding.to_string(); text_preview.truncated = truncated; return; // FileInfo + TextPreview are sufficient for text files. } if fmt != FileFormat::Xpr2Texture { return; // Not a texture — FileInfo is sufficient } // 3D model? XPR2 containers that hold XBG7 geometry (ship / weapon / prop // models under `hidden/resource3d/`) preview as an orbitable mesh, textured // with their albedo (`_col`) map. Complex multi-stream body meshes decline // cleanly (Err) and fall through to the 2D texture preview below. // A container may decode as a single model (weapons / props, via the // single-block layout) OR as a stage — a collection of many enemy / prop // sub-models. A stage's *first* XBG7 is often a tiny dummy "root" node that // the single-model path decodes into a degenerate cube, so we can't just try // it first and stop. Decode both and keep whichever yields more geometry. use sylpheed_formats::mesh::Xbg7Model; let single = Xbg7Model::from_xpr2(&bytes) .ok() .filter(|m| !m.meshes.is_empty()); let single_verts = single.as_ref().map(|m| m.totals().0).unwrap_or(0); let stage = Xbg7Model::stage_models(&bytes); let stage_verts: usize = stage.iter().map(|m| m.totals().0).sum(); if !stage.is_empty() && stage_verts > single_verts { spawn_stage_models( &stage, &bytes, &mut commands, &mut meshes, &mut materials, &mut images, &mut model_preview, &mut orbit, ); return; } if let Some(model) = single { spawn_model_preview( &model, &bytes, &mut commands, &mut meshes, &mut materials, &mut images, &mut model_preview, &mut orbit, ); return; } // World cubemap (`TXCM`) → decode all 6 faces and show a labelled grid. // Returns `None` for ordinary 2D textures, which fall through below. match sylpheed_formats::texture::X360Texture::cube_faces_from_xpr2(&bytes) { Ok(Some(cube)) => { use sylpheed_formats::texture::{Cubemap, X360Texture}; for (i, face) in cube.faces.iter().enumerate() { let face_tex = X360Texture { width: cube.width, height: cube.height, format: cube.format, mip_levels: 1, is_cubemap: false, data: face.clone(), }; if let Ok(img) = crate::asset_loader::x360_texture_to_bevy_image(face_tex) { let handle = images.add(img); let egui_id = contexts.add_image(handle.clone_weak()); skybox.faces.push(FaceTex { label: Cubemap::face_label(i), handle, egui_id, width: cube.width, height: cube.height, }); } } skybox.info = format!( "Cubemap {}×{} {:?} · {} faces", cube.width, cube.height, cube.format, skybox.faces.len() ); return; } Ok(None) => {} // ordinary 2D texture Err(e) => { preview.format_info = format!("Cubemap parse failed: {e}"); return; } } let tex = match sylpheed_formats::X360Texture::from_xpr2(&bytes) { Ok(t) => t, Err(e) => { preview.format_info = format!("XPR2 parse failed: {e}"); return; } }; let w = tex.width; let h = tex.height; let mips = tex.mip_levels; // Canary-sourced format name + metadata (GPUTEXTUREFORMAT), e.g. // "k_DXT1 (Dxt1) · 256×256 · 9 mip(s) · 4 bpp, compressed". let d = tex.format.desc(); let fmt_str = format!( "{} ({:?}) · {}×{} · {} mip(s) · {} bpp, {}", tex.format.gpu_name(), tex.format, w, h, mips, d.bpp, if d.compressed { "compressed" } else { "uncompressed" }, ); let image = match crate::asset_loader::x360_texture_to_bevy_image(tex) { Ok(img) => img, Err(e) => { preview.format_info = format!("Bevy image conversion failed: {e}"); return; } }; let handle = images.add(image); let egui_id = contexts.add_image(handle.clone_weak()); preview.handle = Some(handle); preview.egui_id = Some(egui_id); preview.width = w; preview.height = h; preview.format_info = fmt_str; } /// Consumes a staged pack, freeing the previous texture/text previews and /// populating `PakView` for the master-detail browser. #[cfg(not(target_arch = "wasm32"))] fn apply_pak( mut pending: ResMut, mut pak_view: ResMut, mut preview: ResMut, mut text_preview: ResMut, mut skybox: ResMut, mut video: ResMut, mut engine: NonSendMut, mut file_info: ResMut, mut images: ResMut>, mut contexts: bevy_egui::EguiContexts, ) { if !pending.ready { return; } pending.ready = false; // Free any previous texture/skybox/video + clear the other previews (mirrors // the texture path) so exactly one viewer is active. free_texture(&mut preview, &mut images, &mut contexts); free_skybox(&mut skybox, &mut images, &mut contexts); free_video(&mut video, &mut engine, &mut images, &mut contexts); *text_preview = TextPreview::default(); file_info.name = pending.name.clone(); file_info.size_bytes = 0; file_info.detected_format = None; *pak_view = PakView { loaded: true, name: std::mem::take(&mut pending.name), rows: std::mem::take(&mut pending.rows), selected: None, error: pending.error.take(), ..Default::default() }; } /// Consumes a prepared video: allocates the frame texture, starts the ffmpeg /// frame pipe + (best-effort) audio sink, and populates `VideoPreview`. #[cfg(not(target_arch = "wasm32"))] #[allow(clippy::too_many_arguments)] fn apply_loaded_video( mut pending: ResMut, mut video: ResMut, mut engine: NonSendMut, mut preview: ResMut, mut text_preview: ResMut, mut pak_view: ResMut, mut skybox: ResMut, mut file_info: ResMut, mut images: ResMut>, mut contexts: bevy_egui::EguiContexts, ) { if !pending.ready { return; } pending.ready = false; let Some(prep) = pending.video.take() else { return; }; let prep = *prep; // Free every other preview + any prior video so exactly one viewer is active. free_texture(&mut preview, &mut images, &mut contexts); free_skybox(&mut skybox, &mut images, &mut contexts); free_video(&mut video, &mut engine, &mut images, &mut contexts); *text_preview = TextPreview::default(); *pak_view = PakView::default(); file_info.name = prep.name.clone(); file_info.size_bytes = 0; file_info.detected_format = None; // One RGBA frame texture (starts black), overwritten in place each tick. let image = Image::new_fill( Extent3d { width: prep.width, height: prep.height, depth_or_array_layers: 1, }, TextureDimension::D2, &[0, 0, 0, 255], TextureFormat::Rgba8UnormSrgb, RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, ); let handle = images.add(image); let egui_id = contexts.add_image(handle.clone_weak()); // Start the video frame pipe (clock is paused; frames flow up to the queue). let (child, frames) = match spawn_video_decoder(&prep.temp_video, prep.width, prep.height, prep.fps, 0.0) { Ok(v) => v, Err(e) => { error!("video decode: {e}"); contexts.remove_image(&handle); images.remove(handle.id()); let _ = std::fs::remove_file(&prep.temp_video); if let Some(w) = &prep.temp_wav { let _ = std::fs::remove_file(w); } preview.format_info = e; // shown in the info panel return; } }; // Audio (best-effort): open the device + a sink over the temp WAV, paused. let volume = 0.8_f32; let (stream, stream_handle, sink) = match &prep.temp_wav { Some(wav) => match rodio::OutputStream::try_default() { Ok((stream, handle_out)) => { let sink = build_audio_sink(&handle_out, wav, 0.0, volume, false); (Some(stream), Some(handle_out), sink) } Err(e) => { warn!("no audio output device: {e}"); (None, None, None) } }, None => (None, None, None), }; let has_audio = sink.is_some(); // One-shot frame grabber for live timeline scrubbing. let (scrub_tx, scrub_req_rx) = mpsc::channel::(); let (scrub_frame_tx, scrub_frame_rx) = mpsc::channel::>(); { let path = prep.temp_video.clone(); let (gw, gh) = (prep.width, prep.height); std::thread::spawn(move || scrub_worker(scrub_req_rx, scrub_frame_tx, path, gw, gh)); } engine.inner = Some(VideoEngineInner { frames, child, width: prep.width, height: prep.height, fps: prep.fps, temp_video: prep.temp_video, temp_wav: prep.temp_wav, held: None, scrub_tx, scrub_frame_rx, awaiting_seek_frame: false, _stream: stream, stream_handle, sink, applied_volume: volume, was_playing: false, }); *video = VideoPreview { active: true, name: prep.name, width: prep.width, height: prep.height, duration: prep.duration, position: 0.0, playing: false, handle: Some(handle), egui_id: Some(egui_id), volume, has_audio, seek_request: None, scrub_request: None, scrubbing: false, }; } /// Drives the playing video each frame: honours seek requests, mirrors /// play/pause + volume to the audio sink, advances the wall-clock position, and /// uploads the frame due at that position into the shared texture. #[cfg(not(target_arch = "wasm32"))] fn advance_video_playback( mut video: ResMut, mut engine: NonSendMut, time: Res