//! 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::atomic::{AtomicBool, Ordering}; #[cfg(not(target_arch = "wasm32"))] use std::sync::{mpsc, Arc, 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, /// 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 }, } /// 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, } } } /// Timed subtitles for the currently-playing cutscene, plus the language /// selection. Populated asynchronously (see [`RequestSubtitles`]); the UI /// overlays the active cue on the video frame. Registered on all platforms so /// the UI can read it; the loader that fills it is native-only. #[derive(Resource)] pub struct MovieSubtitles { /// Whether to show captions. pub enabled: bool, /// Selected caption language. pub lang: sylpheed_formats::SubLang, /// Cues for `movie`, in start order. pub cues: Vec, /// Basename (no extension) of the movie the cues belong to. pub movie: Option, /// True while a load is in flight. pub loading: bool, /// Bumped on each request so stale async results are ignored. pub generation: u64, } impl Default for MovieSubtitles { fn default() -> Self { Self { enabled: true, lang: sylpheed_formats::SubLang::English, cues: Vec::new(), movie: None, loading: false, generation: 0, } } } impl MovieSubtitles { /// The caption active at `t` seconds, if any. /// /// Cues with an explicit `start-end` range (the inline narration tracks) use /// it directly. Reference tracks (radio / resupply) carry only a *start* /// time, so we show each line for an estimated reading duration — capped by /// the next cue's start — instead of leaving it on screen until the next line /// (which, for a single-line resupply movie, meant the whole video). pub fn active_at(&self, t: f32) -> Option<&sylpheed_formats::SubCue> { self.active_cues(t).into_iter().next() } /// Every caption active at `t` seconds, in start order. /// /// Cues with an explicit `start-end` range use it directly; range-only tracks /// (radio / resupply) carry a *start* only, so each shows for an estimated /// reading duration capped by the next cue's start. Two captions whose spans /// overlap are **both** returned (some tracks cross-fade lines), so the caller /// can stack them rather than showing only the first. pub fn active_cues(&self, t: f32) -> Vec<&sylpheed_formats::SubCue> { if !self.enabled { return Vec::new(); } let mut out = Vec::new(); for (i, c) in self.cues.iter().enumerate() { if t < c.start { continue; } let end = match c.end { Some(e) => e, None => { // For an open-ended cue, cap at the next cue that starts // *after* it (a later cue may itself start earlier if the // list has overlaps, so scan forward for the first greater // start rather than blindly taking i+1). let next = self .cues .iter() .skip(i + 1) .map(|n| n.start) .find(|&s| s > c.start) .unwrap_or(f32::INFINITY); (c.start + estimated_read_secs(&c.text)).min(next) } }; if t < end { out.push(c); } } out } } /// Estimated on-screen time for a caption that carries no explicit end time, /// from its length (~13 characters/second reading speed), clamped to a sensible /// 2–7 s so a short line still lingers and a long one doesn't overstay. fn estimated_read_secs(text: &str) -> f32 { let chars = text.chars().filter(|c| !c.is_whitespace()).count() as f32; (1.2 + chars / 13.0).clamp(2.0, 7.0) } /// Ask the loader to (re)load subtitles for `movie` in `lang`. Fired on video /// open and on language change. #[derive(Event)] pub struct RequestSubtitles { pub movie: String, pub lang: sylpheed_formats::SubLang, pub generation: u64, } /// The cutscene voice-over track for the current movie. The audio itself lives /// in the video engine (a second rodio sink); this resource holds the UI toggle /// and async-load bookkeeping. Registered on all platforms; the loader is /// native-only. #[derive(Resource)] pub struct MovieVoice { /// Whether the voice track is audible (user toggle). pub enabled: bool, /// Voice language (only English/Japanese exist on disc). pub lang: sylpheed_formats::slb::VoiceLang, /// Basename of the movie the current voice belongs to. pub movie: Option, /// True while decoding. pub loading: bool, /// True once a decoded voice track exists for `movie` (else the movie has none). pub available: bool, /// Bumped per request to discard stale async results. pub generation: u64, /// A freshly-decoded WAV awaiting sink construction on the engine thread. pub(crate) pending_wav: Option, } impl Default for MovieVoice { fn default() -> Self { Self { enabled: true, lang: sylpheed_formats::slb::VoiceLang::English, movie: None, loading: false, available: false, generation: 0, pending_wav: None, } } } /// Ask the loader to decode the voice track for `movie`. `duration` clamps the /// decode to the media length (dropping any trailing bank data past the video). #[derive(Event)] pub struct RequestVoice { pub movie: String, pub lang: sylpheed_formats::slb::VoiceLang, pub duration: f32, pub generation: u64, } /// Standalone audio player: plays a decoded voice/sound track on its own (no /// video), with its own transport. Populated via [`RequestAudio`]. #[derive(Resource)] pub struct AudioPreview { pub active: bool, pub name: String, pub position: f32, pub duration: f32, pub playing: bool, pub volume: f32, pub loading: bool, pub seek_request: Option, /// A freshly-decoded WAV + duration awaiting sink construction. pub(crate) pending: Option<(PathBuf, f32)>, pub(crate) generation: u64, } impl Default for AudioPreview { fn default() -> Self { Self { active: false, name: String::new(), position: 0.0, duration: 0.0, playing: false, volume: 0.9, loading: false, seek_request: None, pending: None, generation: 0, } } } /// Ask the loader to decode a clip for standalone playback (full length, no /// clamp). Either `clip` names the `.slb` entry directly (the Voice Lines /// browser), or `movie` asks the loader to resolve the voice bank from the movie /// manifest (the movie player's 🎧 solo button). `display` is the UI label. #[derive(Event)] pub struct RequestAudio { pub clip: String, pub display: String, /// When set, resolve the `.slb` from the movie manifest instead of `clip`. pub movie: Option<(String, sylpheed_formats::slb::VoiceLang)>, pub generation: u64, } /// The browseable library of voice-line clips (from `sounds.tbl`), for the /// standalone voice player. Loaded lazily the first time the browser opens. #[derive(Resource, Default)] pub struct VoiceLibrary { pub open: bool, pub loading: bool, pub loaded: bool, pub clips: Vec, pub filter: String, } /// Ask the loader to enumerate the voice library from `sounds.tbl`. #[derive(Event, Default)] pub struct RequestVoiceLibrary; // ── Game-data browser (decoded IDXD tables) ──────────────────────────────────── /// Which decoded table the Game Data browser is showing. #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum GameCategory { #[default] Weapons, Craft, Vessels, Characters, Missions, Arsenal, Flights, } impl GameCategory { pub const ALL: [GameCategory; 7] = [ GameCategory::Weapons, GameCategory::Craft, GameCategory::Vessels, GameCategory::Characters, GameCategory::Missions, GameCategory::Arsenal, GameCategory::Flights, ]; pub fn label(self) -> &'static str { match self { GameCategory::Weapons => "Weapons", GameCategory::Craft => "Craft", GameCategory::Vessels => "Capital Ships", GameCategory::Characters => "Characters", GameCategory::Missions => "Missions", GameCategory::Arsenal => "Arsenal", GameCategory::Flights => "Flights", } } } /// A character row, name resolved via localization. pub struct CharRow { pub name: String, pub faction: String, pub faces: usize, } /// A mission row: stage + its resolved briefing. pub struct MissionRow { pub id: String, pub location: String, pub phases: u32, pub objectives: Vec, pub lose: Vec, pub enemies: Vec, } /// Everything the Game Data browser shows, decoded off-thread from the paks. #[derive(Default)] pub struct GameSnapshot { pub weapons: Vec, pub craft: Vec, pub vessels: Vec, pub characters: Vec, pub missions: Vec, pub arsenal: sylpheed_formats::game_data::Arsenal, /// Distinct flight line-ups: `(callsign, pilot)` rows. pub flights: Vec>, } /// The Game Data browser state (decoded combat / mission / cast tables). #[derive(Resource, Default)] pub struct GameData { pub open: bool, pub loading: bool, pub loaded: bool, pub category: GameCategory, pub filter: String, pub snapshot: GameSnapshot, } /// Ask the loader to decode the game-data tables. #[derive(Event, Default)] pub struct RequestGameData; /// One assemblable ship in the Ships browser: an XBG7 part family reconstructed /// into a whole model, with any linked [`sylpheed_formats::game_data::Vessel`] /// stats. #[derive(Clone)] pub struct ShipRow { /// Resource-family id, e.g. `e106`. pub id: String, /// `ADAN` / `TCAF` / `Neutral` / `Other`. pub faction: String, /// Display name (from the linked Vessel, else the id). pub name: String, /// The stage container to render from, e.g. `hidden/resource3d/Stage_S02.xpr`. pub stage_file: String, /// Stage labels the ship appears in (`S02`, `S04`). pub stages: Vec, /// Base part resource names to draw together. pub parts: Vec, /// True when the id matched a `Vessel` recipe (a real capital ship). pub has_vessel: bool, pub hp: Option, pub size: Option<(f32, f32, f32)>, pub turrets: Option, pub bridges: Option, pub shield_gens: Option, } /// The Ships browser state — capital ships assembled from XBG7 part families. #[derive(Resource, Default)] pub struct ShipBrowser { pub open: bool, pub loading: bool, pub loaded: bool, pub filter: String, /// Faction filter: "" = all, else "ADAN"/"TCAF"/"Neutral". pub faction: String, pub rows: Vec, /// Family id of the ship currently rendered (for row highlight). pub selected: Option, /// Also place the approximate external parts (bridge / shield gen / engines at /// their `GN_*` hardpoint frames). Off by default → the exact hull only. pub show_external: bool, } /// Ask the loader to scan the stage containers and build the ship catalog. #[derive(Event, Default)] pub struct RequestShipCatalog; /// Ask the loader to assemble + render one ship's part family. #[derive(Event)] pub struct RequestShipRender { /// Stage container path to read the parts from. pub file: String, /// Ship family id (`e106`) — drives the scene-graph assembly. pub id: String, /// Also place the approximate external hardpoint parts. pub external: bool, /// Display label for the model info line. pub label: String, } /// 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), /// An `XPR2` container fully decoded + prepared off the main thread (meshes /// built, textures decoded). `apply_prepared_xpr` only registers the result /// into Bevy `Assets`, so the heavy work never blocks the UI. `generation` /// lets a stale result (the user clicked something else) be discarded. XprPrepared { generation: u64, prepared: Box, }, /// Subtitles loaded for a cutscene (empty if the movie has none). `movie` + /// `generation` gate stale results when the user switches movie/language. SubtitlesLoaded { movie: String, lang: sylpheed_formats::SubLang, generation: u64, cues: Vec, }, /// Voice track decoded to a temp mono WAV (`wav` is `None` if the movie has /// no voice). `movie` + `generation` gate stale results. VoiceLoaded { movie: String, generation: u64, wav: Option, }, /// Standalone audio decoded (`wav` + duration seconds), or `None` on failure. AudioLoaded { name: String, generation: u64, wav: Option<(PathBuf, f32)>, }, /// The voice-clip library enumerated from `sounds.tbl`. VoiceLibraryLoaded { clips: Vec, }, /// The assembled-ship catalog for the Ships browser. ShipCatalogLoaded(Vec), /// The decoded game-data tables for the browser. GameDataLoaded(Box), /// User dismissed the file dialog — not an error. Cancelled, Error(String), } /// Result of decoding an `XPR2` container off the main thread. Bevy `Mesh` and /// `Image` are CPU-side (no GPU handle until added to `Assets`), so they are /// `Send` and can be fully built on a worker; the applier just registers them. /// The CPU-side texture maps of one material, built off-thread. `_col` → albedo, /// `_spc` (specular) → metallic and `_gls` (gloss) → roughness packed into one /// linear metallic-roughness image (G=roughness, B=metallic), `_lum` → emissive. #[cfg(not(target_arch = "wasm32"))] #[derive(Default)] pub struct PreparedMat { pub albedo: Option, pub metallic_roughness: Option, pub emissive: Option, } #[cfg(not(target_arch = "wasm32"))] pub enum PreparedXpr { /// A 3D model / stage: meshes already baked into world space and merged by /// material, plus the per-material texture maps (slot 0 → neutral tint). Model { meshes: Vec<(Mesh, usize)>, // (mesh, material index) materials: Vec, focus: Vec3, radius: f32, min_radius: f32, max_radius: f32, name: String, info: String, }, /// A 2D texture (`width`/`height` carried on the `Image`). Texture { image: Image, info: String }, /// A world cubemap: six labelled faces. Cubemap { faces: Vec<(&'static str, Image, u32, u32)>, info: String, }, /// Nothing previewable — just a status line (parse error / declined layout). Info(String), /// The decode was cancelled because a newer selection arrived. Cancelled, } /// 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, } /// Native-only playback engine for the standalone [`AudioPreview`]. Held as a /// `NonSend` resource because `rodio::OutputStream` is `!Send`. #[cfg(not(target_arch = "wasm32"))] #[derive(Default)] struct AudioEngine { _stream: Option, handle: Option, sink: Option, wav: Option, applied_volume: f32, was_playing: bool, } /// 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>, } /// Tracks the in-flight off-thread XPR decode so a new selection can supersede /// it: each dispatch bumps `generation` and flips the previous `cancel` flag, so /// the old worker aborts at the next resource boundary and its (now stale) /// result is dropped by `apply_prepared_xpr`. #[cfg(not(target_arch = "wasm32"))] #[derive(Resource, Default)] struct XprLoadState { generation: u64, cancel: Option>, } /// One-frame staging buffer for a prepared XPR container; `apply_prepared_xpr` /// consumes it and registers the meshes / textures into Bevy `Assets`. #[cfg(not(target_arch = "wasm32"))] #[derive(Resource, Default)] struct PendingXpr { ready: bool, generation: u64, prepared: 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, // ── voice-over track (the localized cutscene voice, a second sink) ── /// Decoded mono voice WAV (from `\Movie\VOICE_.slb`), if any. voice_wav: Option, /// The voice sink, layered over the movie's own music+SFX. Muted (volume 0) /// when the voice toggle is off; otherwise mirrors play/pause/seek exactly. voice_sink: Option, /// Last effective voice volume pushed (0.0 = disabled). voice_applied_volume: f32, } // ── 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::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .add_event::() .add_event::() .add_event::() .add_event::() .add_event::() .add_event::() .add_event::(); #[cfg(not(target_arch = "wasm32"))] { app.init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_non_send_resource::() .init_non_send_resource::() .add_systems( Update, ( handle_open_iso, handle_open_dir, handle_file_selected, poll_loader_channel, apply_loaded_texture, apply_prepared_xpr, apply_pak, apply_loaded_video, advance_video_playback, handle_subtitle_request, handle_voice_request, handle_audio_request, advance_audio_playback, handle_voice_library_request, handle_game_data_request, handle_ship_catalog_request, handle_ship_render_request, ) .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(), detail: None, content: PakContent::None, }); continue; } match arc.read(e) { Ok(payload) => { decoded_budget += payload.len(); let format = inner_format_label(&payload); let (identity, 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(), Some(PakDetail { schema_hash: obj.schema_hash, count: obj.count, fields, }), ) } Err(_) => (String::new(), None), } } else { (String::new(), 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, 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(), 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(), }; } 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, mut pending_xpr: ResMut, mut subtitles: ResMut, mut mvoice: ResMut, mut audio_preview: ResMut, mut voice_lib: ResMut, mut game_data: ResMut, mut ships: 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(); // A new game source is loaded — invalidate the voice library so the // browser re-reads sounds.tbl from THIS source next time it opens // (otherwise a stale/failed empty result from before load persists). *voice_lib = VoiceLibrary::default(); 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::XprPrepared { generation, prepared, }) => { pending_xpr.generation = generation; pending_xpr.prepared = Some(prepared); pending_xpr.ready = true; // browser.loading stays true here; `apply_prepared_xpr` clears it // once the (non-stale) result is registered. } Ok(IsoLoaderMsg::SubtitlesLoaded { movie, lang, generation, cues, }) => { // Ignore results for a movie/language the user has moved on from. if generation == subtitles.generation && subtitles.movie.as_deref() == Some(movie.as_str()) && subtitles.lang == lang { subtitles.cues = cues; subtitles.loading = false; } } Ok(IsoLoaderMsg::VoiceLoaded { movie, generation, wav, }) => { let accept = generation == mvoice.generation && mvoice.movie.as_deref() == Some(movie.as_str()); info!( "[voice] loaded movie={movie:?} gen={generation} (cur movie={:?} gen={}) accept={accept} wav={wav:?}", mvoice.movie, mvoice.generation ); if accept { mvoice.loading = false; mvoice.available = wav.is_some(); mvoice.pending_wav = wav; } } Ok(IsoLoaderMsg::AudioLoaded { name, generation, wav, }) => { if generation == audio_preview.generation { audio_preview.loading = false; match wav { Some((p, dur)) => { audio_preview.name = name; audio_preview.pending = Some((p, dur)); } None => audio_preview.active = false, } } } Ok(IsoLoaderMsg::VoiceLibraryLoaded { clips }) => { voice_lib.loading = false; // sounds.tbl always has thousands of clips, so an empty result means // the read failed (e.g. no game source, or opened only a bare pak). // Leave `loaded=false` so re-opening the menu retries instead of // latching an empty list forever. if clips.is_empty() { voice_lib.loaded = false; } else { voice_lib.clips = clips; voice_lib.loaded = true; } } Ok(IsoLoaderMsg::GameDataLoaded(snap)) => { game_data.loading = false; // An empty snapshot means the decode failed (no game source, or a // bare pak) — leave `loaded=false` so re-opening retries. let empty = snap.weapons.is_empty() && snap.craft.is_empty() && snap.missions.is_empty(); if empty { game_data.loaded = false; } else { game_data.snapshot = *snap; game_data.loaded = true; } } Ok(IsoLoaderMsg::ShipCatalogLoaded(rows)) => { ships.loading = false; ships.loaded = !rows.is_empty(); ships.rows = rows; } 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.voice_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); } if let Some(w) = &inner.voice_wav { let _ = std::fs::remove_file(w); } } *video = VideoPreview::default(); } /// Reduce a sub-model / resource name to its **entity stem** — the leading /// `` id that its textures are named after. XBG7 resource names /// carry LOD / part / pose decoration (`e_rou_e007_Near`, `e007_bdy_01_l`, /// `f001_bdy_02`) while the matching albedo map is named for the bare entity /// (`e007_col`, `f001_bdy_01a_col`). Stripping to the stem (`e007`, `f001`) is /// what lets the two be matched. fn entity_stem(name: &str) -> String { let mut s = name.trim_start_matches('_'); // Leading single-letter group prefix (`e_`, `g_`, `j_`, `n_`) then `rou_`. for p in ["e_", "g_", "j_", "n_"] { if let Some(r) = s.strip_prefix(p) { s = r; break; } } if let Some(r) = s.strip_prefix("rou_") { s = r; } // Take the leading token (e.g. `e007`, `f001`). let b = s.as_bytes(); let mut i = 0; while i < b.len() && b[i].is_ascii_alphabetic() { i += 1; } let mut j = i; while j < b.len() && b[j].is_ascii_digit() { j += 1; } if i > 0 && j > i { s[..j].to_ascii_lowercase() } else { s.to_ascii_lowercase() } } /// Pick the index of the albedo (`_col`) texture for a sub-model, matching its /// [`entity_stem`] against the container's texture names. Returns the best `_col` /// candidate (exact `_col`, else shortest `…` name, else any name /// mentioning the stem) or `None` when the entity has no own colour map (it uses /// a shared atlas we can't resolve — the caller shows a neutral tint instead of a /// wrong texture). This replaces the old `ends_with("_col") && starts_with(core)` /// rule, which only matched ~25% of stage sub-models (the rest fell back to flat /// grey — "models render without colour"). fn pick_albedo_index(model_name: &str, tex_names: &[String]) -> Option { let stem = entity_stem(model_name); if stem.is_empty() { return None; } let cols: Vec<(usize, String)> = tex_names .iter() .enumerate() .map(|(i, n)| (i, n.to_ascii_lowercase())) .filter(|(_, n)| n.contains("_col")) .collect(); // 1. Exact `_col`. let exact = format!("{stem}_col"); if let Some((i, _)) = cols.iter().find(|(_, n)| *n == exact) { return Some(*i); } // 2. Names starting with the stem. Prefer the model's PRIMARY body map // (`…_bdy_01a_col`, the 1024² main texture) over the smaller secondary // maps: multi-part ship bodies name several `_col` textures, and the old // "shortest name" rule picked a tiny 128² part map (`f001_bdy_02_col`) for // the whole hull → visibly pixelated. Rank `01a` first, then any `01`, // then fall back to the shortest (closest) name. if let Some((i, _)) = cols .iter() .filter(|(_, n)| n.starts_with(&stem)) .min_by_key(|(_, n)| { let rank = if n.contains("01a") { 0 } else if n.contains("01") { 1 } else { 2 }; (rank, n.len()) }) { return Some(*i); } // 3. Stem mentioned anywhere in a colour map's name. cols.iter().find(|(_, n)| n.contains(&stem)).map(|(i, _)| *i) } /// 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"))] #[allow(clippy::too_many_arguments)] 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 model_preview: ResMut, old_models: Query>, channels: Res, mut xpr_load: ResMut, mut browser: ResMut, ) { if !pending.ready { return; } pending.ready = false; let bytes = std::mem::take(&mut pending.bytes); let path = std::mem::take(&mut pending.path); // Any new selection supersedes an in-flight XPR decode: bump the generation // (so a late worker result is discarded) and flip the previous cancel flag // (so the worker aborts at its next resource boundary). Done for *every* file // type, not just XPR, so switching to e.g. a text file also cancels a stage. xpr_load.generation = xpr_load.generation.wrapping_add(1); if let Some(prev) = xpr_load.cancel.take() { prev.store(true, Ordering::Relaxed); } 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 / model — FileInfo is sufficient. } // XPR2 containers (a 2D texture, a world cubemap, a single model, or a stage // with hundreds of sub-models) can take *seconds* to decode + prepare. Doing // that on this system froze the whole app (OS "not responding", no spinner). // Instead hand the bytes to a worker that does ALL the heavy work — geometry // decode, transform baking, texture decode, mesh building — and returns a // ready-to-register `PreparedXpr`; `apply_prepared_xpr` only touches `Assets`. // The generation was already bumped (and any prior worker cancelled) above. let generation = xpr_load.generation; let cancel = Arc::new(AtomicBool::new(false)); xpr_load.cancel = Some(cancel.clone()); browser.loading = true; // keep the spinner up until the result is applied let sender = channels.sender.clone(); std::thread::spawn(move || { let prepared = prepare_xpr(&bytes, &cancel); let _ = sender.send(IsoLoaderMsg::XprPrepared { generation, prepared: Box::new(prepared), }); }); } /// Decode + fully prepare an `XPR2` container **off the main thread**: geometry /// decode (abortable), transform baking, per-material merge, texture decode, and /// Bevy `Mesh`/`Image` construction. Everything here is CPU-only and `Send`; the /// result is registered into `Assets` by [`apply_prepared_xpr`] on the main /// thread. `cancel` is polled between stage resources so a new selection aborts. #[cfg(not(target_arch = "wasm32"))] fn prepare_xpr(bytes: &[u8], cancel: &Arc) -> PreparedXpr { use sylpheed_formats::mesh::{count_xbg7, Xbg7Model}; let should_cancel = || cancel.load(Ordering::Relaxed); // ── 3D geometry? Route by XBG7 resource count (see the CLI `decode_models`): // >1 → a stage collection (content-anchored, abortable); 1 → a single model // via the validated decode, else the strict content-anchor fallback. ── let models: Vec = if count_xbg7(bytes) > 1 { Xbg7Model::stage_models_cancellable(bytes, &should_cancel) } else { match Xbg7Model::from_xpr2(bytes) { Ok(m) if !m.meshes.is_empty() => vec![m], _ => Xbg7Model::anchor_models_cancellable(bytes, 0.85, &should_cancel), } }; if should_cancel() { return PreparedXpr::Cancelled; } if !models.is_empty() { // A ship file is a base pose + `_mnv*`/`_turn180` animation poses of the // SAME entity. Draw ONLY the base pose (not a 5-copy grid of the poses). if let Some((base, _poses)) = detect_pose_set(&models) { return prepare_models(bytes, std::slice::from_ref(&models[base])); } return prepare_models(bytes, &models); } // ── World cubemap (TXCM): decode all six faces. ── match sylpheed_formats::texture::X360Texture::cube_faces_from_xpr2(bytes) { Ok(Some(cube)) => { use sylpheed_formats::texture::{Cubemap, X360Texture}; let mut faces = Vec::new(); 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) { faces.push((Cubemap::face_label(i), img, cube.width, cube.height)); } } let info = format!( "Cubemap {}×{} {:?} · {} faces", cube.width, cube.height, cube.format, faces.len() ); return PreparedXpr::Cubemap { faces, info }; } Ok(None) => {} // ordinary 2D texture Err(e) => return PreparedXpr::Info(format!("Cubemap parse failed: {e}")), } // ── Ordinary 2D texture. ── let tex = match sylpheed_formats::X360Texture::from_xpr2(bytes) { Ok(t) => t, Err(e) => return PreparedXpr::Info(format!("XPR2 parse failed: {e}")), }; let info = format!( "{:?} {}×{} {} mip(s)", tex.format, tex.width, tex.height, tex.mip_levels ); match crate::asset_loader::x360_texture_to_bevy_image(tex) { Ok(image) => PreparedXpr::Texture { image, info }, Err(e) => PreparedXpr::Info(format!("Bevy image conversion failed: {e}")), } } /// Bake decoded [`Xbg7Model`]s into world-space Bevy meshes merged by material. /// A single model is centred at the origin; several (a stage) are laid out in a /// thumbnail grid, each recentred and uniformly scaled to a fixed cell so all are /// browsable regardless of native size. Runs on the worker thread. /// Pack a specular (`_spc`) and gloss (`_gls`) map into a single linear /// metallic-roughness image (Bevy samples G=roughness, B=metallic): metallic ← /// specular intensity, roughness ← 1 − gloss. Both source maps are the game's /// single-channel maps read from the red channel. Returns `None` unless at least /// one source is an UNCOMPRESSED RGBA8 image — the game's maps are usually /// DXT/BC-compressed (GPU-only), which we can't read on the CPU here, so those /// materials fall back to the scalar metallic/roughness. #[cfg(not(target_arch = "wasm32"))] fn pack_metallic_roughness(spc: Option<&Image>, gls: Option<&Image>) -> Option { use bevy::render::render_asset::RenderAssetUsages; use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; let raw = |img: &&Image| { matches!( img.texture_descriptor.format, TextureFormat::Rgba8Unorm | TextureFormat::Rgba8UnormSrgb ) }; let spc = spc.filter(raw); let gls = gls.filter(raw); let base = spc.or(gls)?; let w = base.texture_descriptor.size.width.max(1); let h = base.texture_descriptor.size.height.max(1); // Nearest-sample the red channel of a (possibly differently-sized) source. let samp = |img: Option<&Image>, x: u32, y: u32, dflt: u8| -> u8 { let Some(img) = img else { return dflt }; let iw = img.texture_descriptor.size.width.max(1); let ih = img.texture_descriptor.size.height.max(1); let sx = (x * iw / w).min(iw - 1); let sy = (y * ih / h).min(ih - 1); img.data.get(((sy * iw + sx) * 4) as usize).copied().unwrap_or(dflt) }; let mut data = vec![0u8; (w * h * 4) as usize]; for y in 0..h { for x in 0..w { let i = ((y * w + x) * 4) as usize; data[i + 1] = 255 - samp(gls, x, y, 96); // G = roughness (no gloss → mid) data[i + 2] = samp(spc, x, y, 0); // B = metallic (no spec → dielectric) data[i + 3] = 255; } } Some(Image::new( Extent3d { width: w, height: h, depth_or_array_layers: 1 }, TextureDimension::D2, data, TextureFormat::Rgba8Unorm, // linear — metallic/roughness are not colour RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, )) } #[cfg(not(target_arch = "wasm32"))] fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) -> PreparedXpr { prepare_models_impl(bytes, models, false) } /// Assemble several XBG7 resources into ONE whole model in a shared coordinate /// frame — the capital-ship case, where each part (`e106_bdy_01`, `e106_brg_01`, /// …) already carries its ship-local position. Unlike the stage path, the parts /// are NOT laid out in a grid and are recentred by a single shared bbox so their /// relative placement is preserved. See [`sylpheed_formats::ship`]. #[cfg(not(target_arch = "wasm32"))] fn prepare_ship(bytes: &[u8], parts: &[sylpheed_formats::mesh::Xbg7Model]) -> PreparedXpr { prepare_models_impl(bytes, parts, true) } #[cfg(not(target_arch = "wasm32"))] fn prepare_models_impl( bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model], assemble: bool, ) -> PreparedXpr { use bevy::render::mesh::{Indices, PrimitiveTopology}; use sylpheed_formats::texture::X360Texture; const CELL: f32 = 10.0; const GAP: f32 = 4.0; const MAX_EXTENT: f32 = 20_000.0; // drop skybox planes / stray-vertex anchors let pitch = CELL + GAP; // Grid layout only for a true multi-model stage browse — an assembled ship // keeps its parts in one shared space. let is_stage = models.len() > 1 && !assemble; // Keep models with real, bounded geometry. let drawn: Vec<&sylpheed_formats::mesh::Xbg7Model> = models .iter() .filter(|m| { let mut lo = Vec3::splat(f32::MAX); let mut hi = Vec3::splat(f32::MIN); let mut has = false; for sub in &m.meshes { if sub.positions.is_empty() || sub.indices.is_empty() { continue; } has = true; for p in &sub.positions { lo = lo.min(Vec3::from_array(*p)); hi = hi.max(Vec3::from_array(*p)); } } has && (hi - lo).max_element() <= MAX_EXTENT }) .collect(); if drawn.is_empty() { return PreparedXpr::Info("No previewable geometry in this container.".to_string()); } let cols = (drawn.len() as f32).sqrt().ceil().max(1.0) as usize; // Map each TX2D texture index to a material slot, decoding each used texture // exactly once. `materials[0]` is always the neutral tint (slot 0). let tex_names = X360Texture::texture_names(bytes); let mut materials: Vec = vec![PreparedMat::default()]; // slot 0 = neutral tint let mut tex_slot: std::collections::HashMap = std::collections::HashMap::new(); // Decode a TX2D to a Bevy image, by index or by name. let load_idx = |ti: usize| -> Option { X360Texture::from_xpr2_index(bytes, ti) .ok() .and_then(|t| crate::asset_loader::x360_texture_to_bevy_image(t).ok()) }; let load_named = |name: &str| -> Option { let ti = tex_names.iter().position(|t| t.eq_ignore_ascii_case(name))?; load_idx(ti) }; let slot_for_ti = |ti: usize, materials: &mut Vec, tex_slot: &mut std::collections::HashMap| -> usize { if let Some(&slot) = tex_slot.get(&ti) { return slot; } let albedo = load_idx(ti); // Sibling maps share the base name with a different suffix // (`…_col` → `…_spc` / `…_gls` / `…_lum`). let base = tex_names .get(ti) .and_then(|n| n.strip_suffix("_col").or(Some(n.as_str()))) .unwrap_or("") .to_string(); let spc = (!base.is_empty()).then(|| load_named(&format!("{base}_spc"))).flatten(); let gls = (!base.is_empty()).then(|| load_named(&format!("{base}_gls"))).flatten(); let emissive = (!base.is_empty()).then(|| load_named(&format!("{base}_lum"))).flatten(); let metallic_roughness = pack_metallic_roughness(spc.as_ref(), gls.as_ref()); let slot = materials.len(); materials.push(PreparedMat { albedo, metallic_roughness, emissive }); tex_slot.insert(ti, slot); slot }; // Accumulate world-space vertices per material so the main thread spawns only // ~one mesh per material instead of hundreds of entities. struct Buf { pos: Vec<[f32; 3]>, nrm: Vec<[f32; 3]>, uv: Vec<[f32; 2]>, idx: Vec, } let mut buffers: Vec = vec![Buf { pos: vec![], nrm: vec![], uv: vec![], idx: vec![], }]; let ensure = |buffers: &mut Vec, n: usize| { while buffers.len() <= n { buffers.push(Buf { pos: vec![], nrm: vec![], uv: vec![], idx: vec![], }); } }; let mut world_lo = Vec3::splat(f32::MAX); let mut world_hi = Vec3::splat(f32::MIN); let mut total_v = 0usize; let mut total_t = 0usize; // Assembled ship: one shared recentre across ALL parts so each part keeps its // ship-local offset (bridge fore, engines aft). Computed from the raw parts — // ship parts carry no node transform, so raw == placed. let shared_center = if assemble { let mut lo = Vec3::splat(f32::MAX); let mut hi = Vec3::splat(f32::MIN); for m in &drawn { for sub in &m.meshes { for p in &sub.positions { lo = lo.min(Vec3::from_array(*p)); hi = hi.max(Vec3::from_array(*p)); } } } (lo.x <= hi.x).then(|| (lo + hi) * 0.5) } else { None }; for (i, model) in drawn.iter().enumerate() { // Per-model bbox → recentre + (stage) uniform scale to the grid 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; } // An assembled ship uses the shared centre (preserve relative placement); // otherwise recentre each model on its own bbox. let center = shared_center.unwrap_or((lo + hi) * 0.5); let (scale, cell) = if is_stage { let extent = (hi - lo).max_element().max(1e-3); let col = i % cols; let row = i / cols; ( CELL / extent, Vec3::new(col as f32 * pitch, -(row as f32) * pitch, 0.0), ) } else { (1.0, Vec3::ZERO) }; // XBG7 scene-graph node placements: the parts are authored around the // origin in the rigid vertex buffer and posed by per-node transforms the // game applies in the shader (translation to the tail, V-tail cant, and a // mirrored copy for each L/R pair). Recover the full affine per drawn // instance, keyed on the geometry (sub_index). Stages skip this. let placements = if is_stage { Vec::new() } else { sylpheed_formats::mesh::node_transforms(bytes, &model.name) }; for (sub_idx, sub) in model.meshes.iter().enumerate() { if sub.positions.is_empty() || sub.indices.is_empty() { continue; } let has_uv = sub.uvs.len() == sub.positions.len(); let has_nrm = sub.normals.len() == sub.positions.len(); // Split the sub-mesh into its XBG7 material draw-groups so each slice // gets its authored albedo. The Delta Saber body is 9 groups (bdy_01a // hull + bdy_01b + bdy_02/03 + daiza stand); the fins are single-group // (bdy_04/06/07). Stages keep the cheap per-model stem match; when the // graph yields nothing, fall back to one stem-matched albedo. let groups = if is_stage { Vec::new() } else { sylpheed_formats::mesh::material_groups(bytes, &model.name, sub.indices.len()) }; let slices: Vec<(usize, usize, Option)> = if groups.is_empty() { vec![(0, sub.indices.len(), pick_albedo_index(&model.name, &tex_names))] } else { groups .iter() .map(|g| { let ti = tex_names.iter().position(|t| t.eq_ignore_ascii_case(&g.albedo)); (g.idx_offset, g.idx_count, ti) }) .collect() }; // Every scene-graph instance that draws this geometry (a mirrored fin // pair, L/R winglets…); `None` = no placement → identity. let instances: Vec> = { let v: Vec<_> = placements .iter() .filter(|p| p.sub_index == sub_idx && p.vtx_count == sub.positions.len()) .map(Some) .collect(); if v.is_empty() { vec![None] } else { v } }; for place in instances { let reflect = place.map(|p| p.reflect).unwrap_or(false); total_v += sub.positions.len(); total_t += sub.indices.len() / 3; // Positions: apply the node affine, then recentre + (stage) scale. let tpos: Vec<[f32; 3]> = sub .positions .iter() .map(|p| { let local = place.map(|pl| pl.apply(*p)).unwrap_or(*p); let wp = (Vec3::from_array(local) - center) * scale + cell; world_lo = world_lo.min(wp); world_hi = world_hi.max(wp); wp.to_array() }) .collect(); // Normals: rotate by the (orthogonal) linear part; a reflected // instance's winding is reversed below so faces stay outward. let tnrm: Vec<[f32; 3]> = if has_nrm { sub.normals .iter() .map(|n| match place { Some(pl) => Vec3::new( pl.m[0][0] * n[0] + pl.m[0][1] * n[1] + pl.m[0][2] * n[2], pl.m[1][0] * n[0] + pl.m[1][1] * n[1] + pl.m[1][2] * n[2], pl.m[2][0] * n[0] + pl.m[2][1] * n[1] + pl.m[2][2] * n[2], ) .normalize_or_zero() .to_array(), None => *n, }) .collect() } else { compute_smooth_normals(&tpos, &sub.indices) }; for (off, cnt, ti) in &slices { let mat = match ti { Some(ti) => slot_for_ti(*ti, &mut materials, &mut tex_slot), None => 0, }; ensure(&mut buffers, mat); let end = (off + cnt).min(sub.indices.len()); // Append the slice's triangles, re-emitting vertices per index // (no dedup) so each material buffer stays self-contained. A // reflected instance swaps two corners to keep front faces out. for tri in sub.indices[(*off).min(end)..end].chunks_exact(3) { let corners = if reflect { [tri[0], tri[2], tri[1]] } else { [tri[0], tri[1], tri[2]] }; for &vi in &corners { let vi = vi as usize; if vi >= tpos.len() { continue; } let b = &mut buffers[mat]; let next = b.pos.len() as u32; b.pos.push(tpos[vi]); b.nrm.push(tnrm[vi]); b.uv.push(if has_uv { sub.uvs[vi] } else { [0.0, 0.0] }); b.idx.push(next); } } } } } } // Build one Bevy mesh per non-empty material buffer. let mut meshes: Vec<(Mesh, usize)> = Vec::new(); for (mat, buf) in buffers.into_iter().enumerate() { if buf.pos.is_empty() || buf.idx.is_empty() { continue; } let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::default()); mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, buf.pos); mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, buf.nrm); mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, buf.uv); mesh.insert_indices(Indices::U32(buf.idx)); meshes.push((mesh, mat)); } let center = if world_lo.x <= world_hi.x { (world_lo + world_hi) * 0.5 } else { Vec3::ZERO }; let diag = if world_lo.x <= world_hi.x { (world_hi - world_lo).length().max(CELL) } else { CELL }; let (name, info) = if assemble { ( "ship".to_string(), format!( "Assembled ship · {} parts · {} verts · {} tris", drawn.len(), total_v, total_t ), ) } else if is_stage { ( "stage".to_string(), format!( "Stage container · {} sub-models (thumbnail grid) · {} verts · {} tris \ (content-anchored; quantized hero bodies skipped)", drawn.len(), total_v, total_t ), ) } else { ( drawn[0].name.clone(), format!( "XBG7 model · {} sub-mesh(es) · {} verts · {} tris", drawn[0].meshes.len(), total_v, total_t ), ) }; PreparedXpr::Model { meshes, materials, focus: center, radius: diag * if is_stage { 0.55 } else { 0.7 }, min_radius: (diag * 0.02).max(0.02), max_radius: diag * 3.0, name, info, } } /// True when a resource name is a maneuver / turn morph pose (`_rou_f001_mnv01_L`, /// `_rou_f001_turn180`) rather than the base pose (`f001`). #[cfg(not(target_arch = "wasm32"))] fn is_pose_name(name: &str) -> bool { let l = name.to_ascii_lowercase(); l.contains("mnv") || l.contains("turn") } /// Recognise a ship container: several XBG7 resources that are one base pose plus /// `_mnv*`/`_turn180` morph poses of the SAME entity, all sharing topology (so /// the poses can be blended per-vertex). Returns `(base_index, pose_indices)`, /// or `None` for a genuine multi-entity stage / a single model. #[cfg(not(target_arch = "wasm32"))] fn detect_pose_set(models: &[sylpheed_formats::mesh::Xbg7Model]) -> Option<(usize, Vec)> { if models.len() < 2 { return None; } // All resources must belong to one entity (single stem) — else it's a stage. let stem0 = entity_stem(&models[0].name); if stem0.is_empty() || !models.iter().all(|m| entity_stem(&m.name) == stem0) { return None; } let bases: Vec = (0..models.len()) .filter(|&i| !is_pose_name(&models[i].name)) .collect(); if bases.len() != 1 { return None; // ambiguous — don't guess } let base = bases[0]; let base_v: usize = models[base].meshes.iter().map(|m| m.positions.len()).sum(); if base_v == 0 { return None; } // Poses must share the base vertex count (same topology → valid morph). let poses: Vec = (0..models.len()) .filter(|&i| i != base && is_pose_name(&models[i].name)) .filter(|&i| { let v: usize = models[i].meshes.iter().map(|m| m.positions.len()).sum(); v == base_v }) .collect(); if poses.is_empty() { return None; } Some((base, poses)) } /// Register a worker-prepared [`PreparedXpr`] into Bevy `Assets` on the main /// thread (cheap: no decode, just `Assets::add` + entity spawns). Discards a /// result whose generation was superseded by a newer selection. #[cfg(not(target_arch = "wasm32"))] #[allow(clippy::too_many_arguments)] fn apply_prepared_xpr( mut pending: ResMut, xpr_load: Res, mut browser: ResMut, mut preview: ResMut, mut skybox: ResMut, mut images: ResMut>, mut meshes: ResMut>, mut materials: ResMut>, mut contexts: bevy_egui::EguiContexts, mut commands: Commands, mut model_preview: ResMut, old_models: Query>, mut orbit: Query<&mut crate::camera::OrbitCamera>, ) { if !pending.ready { return; } pending.ready = false; let prepared = match pending.prepared.take() { Some(p) => p, None => return, }; // Drop a stale result: the user has since selected a different file. if pending.generation != xpr_load.generation { return; } browser.loading = false; match *prepared { PreparedXpr::Cancelled => {} PreparedXpr::Info(msg) => { preview.format_info = msg; } PreparedXpr::Texture { image, info } => { let w = image.width(); let h = image.height(); 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 = info; } PreparedXpr::Cubemap { faces, info } => { for (label, image, width, height) in faces { let handle = images.add(image); let egui_id = contexts.add_image(handle.clone_weak()); skybox.faces.push(FaceTex { label, handle, egui_id, width, height, }); } skybox.info = info; } PreparedXpr::Model { meshes: prepared_meshes, materials: prepared_mats, focus, radius, min_radius, max_radius, name, info, } => { // Despawn any prior model here (rather than at dispatch) so the last // preview stays under the spinner until this result actually lands. for e in old_models.iter() { commands.entity(e).despawn_recursive(); } // One StandardMaterial per prepared material slot — albedo (`_col`), // a packed metallic-roughness map (`_spc`/`_gls`, when uncompressed), // and emissive (`_lum`, e.g. the fin's glow). let mat_handles: Vec> = prepared_mats .into_iter() .map(|mat| { let base_color_texture = mat.albedo.map(|i| images.add(i)); let metallic_roughness_texture = mat.metallic_roughness.map(|i| images.add(i)); let has_emissive = mat.emissive.is_some(); let emissive_texture = mat.emissive.map(|i| images.add(i)); materials.add(StandardMaterial { base_color: if base_color_texture.is_some() { Color::WHITE } else { Color::srgb(0.72, 0.74, 0.78) }, base_color_texture, metallic_roughness_texture, // The game's ship shader (PS 0x01F962B4DCFEB577) lights the // hull mostly with an HDR environment cubemap reflection, so // the metal must be reflective for the viewer's // EnvironmentMapLight to show. Metallic hull, fairly smooth; // the packed map overrides per-pixel where present. perceptual_roughness: 0.4, metallic: 0.7, // The lum mask is boosted ~1.7× in the game (PS c65.z ≈ 1.6–1.8). emissive: if has_emissive { LinearRgba::rgb(1.7, 1.7, 1.7) } else { LinearRgba::BLACK }, emissive_texture, cull_mode: None, double_sided: true, ..default() }) }) .collect(); let root = commands .spawn(( PreviewModel, Transform::default(), Visibility::default(), Name::new(name.clone()), )) .id(); for (mesh, mat) in prepared_meshes { let material = mat_handles .get(mat) .cloned() .unwrap_or_else(|| mat_handles[0].clone()); let child = commands .spawn(( PreviewModel, Mesh3d(meshes.add(mesh)), MeshMaterial3d(material), Transform::default(), )) .id(); commands.entity(root).add_child(child); } if let Ok(mut cam) = orbit.get_single_mut() { cam.focus = focus; cam.radius = radius; cam.min_radius = min_radius; cam.max_radius = max_radius; } model_preview.active = true; model_preview.name = name; model_preview.info = info; } } } /// 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, mut subtitles: ResMut, mut sub_events: EventWriter, mut mvoice: ResMut, mut voice_events: EventWriter, mut audio_preview: ResMut, ) { if !pending.ready { return; } pending.ready = false; let Some(prep) = pending.video.take() else { return; }; // Opening a video stops any standalone audio player. audio_preview.active = false; 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, voice_wav: None, voice_sink: None, voice_applied_volume: 0.0, }); *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, }; // Kick off subtitle loading for this movie in the current language. The // movie basename is the file name without its `.wmv` extension. let movie = video .name .rsplit('/') .next() .unwrap_or(&video.name) .strip_suffix(".wmv") .unwrap_or(&video.name) .to_string(); subtitles.generation = subtitles.generation.wrapping_add(1); subtitles.movie = Some(movie.clone()); subtitles.cues.clear(); subtitles.loading = true; sub_events.send(RequestSubtitles { movie: movie.clone(), lang: subtitles.lang, generation: subtitles.generation, }); // Kick off voice-track decoding for this movie (clamped to its length). mvoice.generation = mvoice.generation.wrapping_add(1); mvoice.movie = Some(movie.clone()); mvoice.available = false; mvoice.loading = true; mvoice.pending_wav = None; voice_events.send(RequestVoice { movie, lang: mvoice.lang, duration: video.duration, generation: mvoice.generation, }); } /// Read a `.pak` archive (+ its `.pNN` segments) from the current source, /// blocking. Mirrors the pak-browser loader but returns a live `PakArchive`. #[cfg(not(target_arch = "wasm32"))] fn read_pak_archive_blocking( source: &SourceKind, pak_path: &str, ) -> Result { let base = pak_path .strip_suffix(".pak") .ok_or_else(|| format!("not a .pak path: {pak_path}"))? .to_string(); match source { SourceKind::Iso(iso_path) => { let iso_path = iso_path.clone(); let pak_path = pak_path.to_string(); futures::executor::block_on(async move { 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, } } sylpheed_formats::PakArchive::from_parts(&index, data).map_err(|e| e.to_string()) }) } SourceKind::Directory(root) => { let assets = sylpheed_formats::vfs::GameAssets::from_directory(root); 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, } } sylpheed_formats::PakArchive::from_parts(&index, data).map_err(|e| e.to_string()) } SourceKind::None => Err("no source open".to_string()), } } /// Handles a [`RequestSubtitles`]: loads the language track + text pack off the /// main thread and posts the resolved cues back via the loader channel. #[cfg(not(target_arch = "wasm32"))] fn handle_subtitle_request( mut events: EventReader, iso_state: Res, channels: Res, ) { // Only act on the latest request (rapid language toggles coalesce). let Some(req) = events.read().last() else { return; }; let source = iso_state.source_kind.clone(); let sender = channels.sender.clone(); let (movie, lang, generation) = (req.movie.clone(), req.lang, req.generation); std::thread::spawn(move || { let cues = (|| -> Result, String> { let lang_pak = read_pak_archive_blocking(&source, &format!("dat/movie/{}.pak", lang.pak_code()))?; let text_pak = read_pak_archive_blocking( &source, &format!("dat/GP_MAIN_GAME_{}.pak", lang.game_code()), )?; Ok(sylpheed_formats::movie_subtitle::load(&movie, &lang_pak, &text_pak)) })() .unwrap_or_default(); let _ = sender.send(IsoLoaderMsg::SubtitlesLoaded { movie, lang, generation, cues, }); }); } /// Read a whole file from the current source, blocking. #[cfg(not(target_arch = "wasm32"))] fn read_source_file(source: &SourceKind, path: &str) -> Result, String> { match source { SourceKind::Iso(iso_path) => { let iso_path = iso_path.clone(); let path = path.to_string(); futures::executor::block_on(async move { let mut reader = sylpheed_formats::xiso::open_iso(&iso_path) .await .map_err(|e| e.to_string())?; reader.read_file(&path).await.map_err(|e| e.to_string()) }) } SourceKind::Directory(root) => { std::fs::read(root.join(path)).map_err(|e| e.to_string()) } SourceKind::None => Err("no source open".to_string()), } } /// Read a single byte range `[off, off+size)` out of a segmented pack /// (`.p00`, `.p01`, …) without materialising the whole ~1 GB archive. /// Directory sources seek into just the right segment(s); ISO sources read the /// segment files up to the one covering the range and slice. #[cfg(not(target_arch = "wasm32"))] fn read_segment_range( source: &SourceKind, base: &str, off: u64, size: usize, ) -> Result, String> { use std::io::{Read, Seek, SeekFrom}; let mut out = Vec::with_capacity(size); let mut skip = off; let mut need = size; for i in 0..100u32 { if need == 0 { break; } let seg = format!("{base}.p{i:02}"); match source { SourceKind::Directory(root) => { let path = root.join(&seg); let Ok(meta) = std::fs::metadata(&path) else { break }; let seg_len = meta.len(); if skip >= seg_len { skip -= seg_len; continue; } let mut f = std::fs::File::open(&path).map_err(|e| e.to_string())?; f.seek(SeekFrom::Start(skip)).map_err(|e| e.to_string())?; let take = need.min((seg_len - skip) as usize); let start = out.len(); out.resize(start + take, 0); f.read_exact(&mut out[start..]).map_err(|e| e.to_string())?; need -= take; skip = 0; } SourceKind::Iso(_) => { // No partial read on ISO; read the whole segment and slice. let Ok(bytes) = read_source_file(source, &seg) else { break }; let seg_len = bytes.len() as u64; if skip >= seg_len { skip -= seg_len; continue; } let take = need.min((seg_len - skip) as usize); out.extend_from_slice(&bytes[skip as usize..skip as usize + take]); need -= take; skip = 0; } SourceKind::None => return Err("no source open".to_string()), } } if need != 0 { return Err(format!("segment range short by {need} bytes")); } Ok(out) } /// Read one `sound.pak` entry (a `.slb` bank) by name-hash, reading only its /// byte range from the segments. #[cfg(not(target_arch = "wasm32"))] fn read_sound_entry(source: &SourceKind, name_hash: u32) -> Result, String> { let toc = read_source_file(source, "dat/sound.pak")?; let entries = sylpheed_formats::PakArchive::parse_toc(&toc).map_err(|e| e.to_string())?; let idx = entries .binary_search_by_key(&name_hash, |e| e.name_hash) .map_err(|_| "voice not present in sound.pak".to_string())?; let e = &entries[idx]; read_segment_range(source, "dat/sound", e.offset as u64, e.comp_size as usize) } /// Decode a `sound.pak` clip (by its `.slb` entry name) to a temp **mono** WAV /// (left channel — the source is mono, sometimes only in the left channel). /// `duration` clamps the decode (`INFINITY` = full clip). Returns the WAV path. #[cfg(not(target_arch = "wasm32"))] fn decode_sound_clip( source: &SourceKind, clip_name: &str, duration: f32, ) -> Result { use sylpheed_formats::{hash::name_hash, slb}; let key = name_hash(clip_name); let bytes = read_sound_entry(source, key)?; // A `.slb` is an XACT bank of one or more sub-waves. Decode EVERY sub-wave and // concatenate them, then clamp to the movie length. This is robust to both bank // shapes we see: segment banks (e.g. VOICE_RT07A = 3 sub-waves 24+14+11s ≈ the // 50s movie) yield the full dialogue, while the clamp drops the extra ALTERNATE // takes of full-length banks (e.g. VOICE_S00A, whose sub-wave 0 already spans // the 94s movie). Playing only sub-wave 0 (the old behaviour) dropped 2/3 of the // dialogue for segment banks — the "RT voice wrong" bug. Verified vs real durations. let mut riffs = slb::to_xma_riffs(&bytes); if riffs.is_empty() { // Some banks (data-before-header `\etc\` radio clips) confuse the // multi-sub-wave scanner; the robust single-stream decoder handles them, // so the standalone browser can still play them. riffs = slb::to_xma_riff_best(&bytes).into_iter().collect(); } decode_riffs_to_wav(riffs, &format!("{key:08x}"), duration) } /// Decode a set of XMA sub-wave RIFFs into a single mono WAV: concatenate them, /// downmix to mono (left channel holds the signal for dual-mono sources), and /// clamp to `duration` seconds. Shared by the per-`.slb` decoder and the /// continuous movie-voice decoder. #[cfg(not(target_arch = "wasm32"))] fn decode_riffs_to_wav(riffs: Vec>, tag: &str, duration: f32) -> Result { if riffs.is_empty() { return Err("no decodable audio".into()); } let dir = std::env::temp_dir(); let out_path = dir.join(format!("sylph_voice_{tag}.wav")); let mut inputs = Vec::with_capacity(riffs.len()); for (i, riff) in riffs.iter().enumerate() { let p = dir.join(format!("sylph_voice_{tag}_{i}.xma.wav")); std::fs::write(&p, riff).map_err(|e| e.to_string())?; inputs.push(p); } let dur = if duration.is_finite() && duration > 0.0 { duration } else { 600.0 }; let filter = format!( "{}concat=n={}:v=0:a=1,pan=mono|c0=c0[a]", (0..inputs.len()).map(|i| format!("[{i}:a]")).collect::(), inputs.len(), ); let mut cmd = Command::new("ffmpeg"); cmd.args(["-hide_banner", "-y"]); for p in &inputs { cmd.arg("-i").arg(p); } cmd.args(["-filter_complex", &filter, "-map", "[a]", "-t"]) .arg(format!("{dur}")) .arg(&out_path); let status = cmd.output(); for p in &inputs { let _ = std::fs::remove_file(p); } match status { Ok(o) if o.status.success() && out_path.metadata().map(|m| m.len() > 44).unwrap_or(false) => { Ok(out_path) } Ok(o) => Err(format!( "ffmpeg voice decode failed: {}", String::from_utf8_lossy(&o.stderr).lines().last().unwrap_or("") )), Err(e) => Err(format!("spawning ffmpeg: {e}")), } } /// Voice token for a hokyu (resupply) cutscene the manifest leaves unbound. /// /// Only 5 of the 18 hokyu movies carry an explicit `VOICETRACK`; the rest reuse /// those 5 recordings. The selector is the cutscene's **demo id** (from its /// subtitle track), NOT the ship/source category: `hokyu_LS_s02A` and /// `hokyu_LS_s11A` are both LS/carrier but use demos 600 vs 601 → `VOICE_D_450` /// vs `_451` (their lines differ: "Rhino 3 has landed" vs "Rhino Leader has /// landed"). We derive the demo→token map from the 5 BOUND hokyu (each has both a /// subtitle demo id and a VOICETRACK), then look up the target movie's demo id. /// Returns `None` for hokyu with no voice cue (`hokyu_LS_s24A`/`s27A` — correctly /// silent) and for non-hokyu movies (they resolve via the manifest only). #[cfg(not(target_arch = "wasm32"))] fn hokyu_voice_token( source: &SourceKind, movie: &str, lang: sylpheed_formats::slb::VoiceLang, manifest: &[u8], ) -> Option { use sylpheed_formats::{movie_manifest, movie_subtitle as ms}; if !movie.starts_with("hokyu_") { return None; } let lang_pak = read_pak_archive_blocking(source, &format!("dat/movie/{}.pak", lang.code_pub())).ok()?; // The target cutscene's demo id (its subtitle's single voice cue). let want = ms::track_voice_cues(&lang_pak, movie).first().map(|&(d, _)| d)?; // Match it against a bound hokyu carrying the same demo id → that VOICETRACK. movie_manifest::parse(manifest).into_iter().find_map(|e| { let tok = e.voice_token.filter(|_| e.movie.starts_with("hokyu_"))?; ms::track_voice_cues(&lang_pak, &e.movie) .iter() .any(|&(d, _)| d == want) .then_some(tok) }) } /// Resolve a movie's cutscene voice to a **continuous byte region** of the /// `sound.pNN` voice stream, in `[start, end)` global offsets. /// /// The movie voices are one continuous XMA stream chunked into `VOICE_*.slb` TOC /// entries whose boundaries do NOT match the cutscene cues (a cue routinely spans /// two `.slb` chunks — so a `.slb` need not hold the track its name claims). Each /// cue ends at an inline `(sound_id, 0x11, …)` trailer; cue N = the bytes between /// trailer N-1 and trailer N. Chain: movie → cue name (manifest VOICETRACK) → /// sound-id (master registry) → region (scan the stream for the two trailers). /// Returns `None` for movies whose voice is not a `\Movie\` bank (e.g. hokyu /// `\etc\` clips), which the caller then resolves the legacy per-clip way. #[cfg(not(target_arch = "wasm32"))] fn resolve_movie_voice_region( source: &SourceKind, movie: &str, lang: sylpheed_formats::slb::VoiceLang, ) -> Option<(u64, u64)> { use sylpheed_formats::{hash::name_hash, movie_manifest, movie_voice, PakArchive}; let code = lang.code_pub(); let tpak = read_pak_archive_blocking(source, "dat/tables.pak").ok()?; // movie → cue token (e.g. "VOICE_RT01A") let manifest = tpak .entries() .iter() .find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))?; let token = movie_manifest::voice_token(&manifest, movie) .or_else(|| hokyu_voice_token(source, movie, lang, &manifest))?; // token → sound-id via the master registry (the large per-language IDXD entry // carrying the `\Movie\VOICE_*.slb` paths). let marker = format!("{code}\\Movie\\VOICE_ADV.slb"); let registry = tpak.entries().iter().find_map(|e| { tpak.read(e) .ok() .filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes())) })?; let id = *movie_voice::registry_voice_ids(®istry).get(&token)?; // physical anchor: the TOC offset of this token's own `.slb` chunk — a start // point near the cue's trailers (the cue itself may sit before/after it). The // token's subdir varies: `Movie` (ADV/RT/S cutscenes) or `etc` (hokyu VOICE_D). let stoc = read_source_file(source, "dat/sound.pak").ok()?; let entries = PakArchive::parse_toc(&stoc).ok()?; let anchor = ["Movie", "etc", "Voice"].iter().find_map(|dir| { let h = name_hash(&format!("{code}\\{dir}\\{token}.slb")); entries .binary_search_by_key(&h, |e| e.name_hash) .ok() .map(|i| entries[i].offset as u64) })?; // Scan a window spanning both directions from the anchor for this cue's trailer // and its predecessor. The window must cover the largest bank (ADV ≈ 3.6 MB). let win_start = anchor.saturating_sub(2 * 1024 * 1024) & !3; let window = read_segment_range(source, "dat/sound", win_start, 8 * 1024 * 1024).ok()?; let end_local = movie_voice::find_descriptor(&window, id)?; let end = win_start + end_local as u64; // Cue start = its predecessor trailer. Prefer the exact `id-1` trailer; if the // id sequence has a gap (e.g. VOICE_D_453→454), fall back to the nearest trailer // below this one — but only if it's within one bank (~1.5 MB), else this is the // first cue in its block and the audio starts at the bank anchor itself. let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1)) .or_else(|| movie_voice::find_descriptor_before(&window, end_local)) .map(|o| win_start + o as u64) .filter(|&s| s < end && end - s < 1_500_000) .unwrap_or(anchor); Some((start, end)) } /// Decode a continuous movie-voice byte region (from [`resolve_movie_voice_region`]) /// into a mono WAV, clamped to `duration`. #[cfg(not(target_arch = "wasm32"))] fn decode_voice_region( source: &SourceKind, start: u64, end: u64, duration: f32, ) -> Result { use sylpheed_formats::slb; let bytes = read_segment_range(source, "dat/sound", start, (end - start) as usize)?; let mut riffs = slb::to_xma_riffs(&bytes); if riffs.is_empty() { riffs = slb::to_xma_riff_best(&bytes).into_iter().collect(); } decode_riffs_to_wav(riffs, &format!("mv_{start:x}"), duration) } /// Resolve a movie's `sound.pak` voice-entry name via the **movie manifest** /// (`tables.pak`), which authoritatively binds each movie to its voice bank — /// several `hokyu_*` resupply movies point at in-mission `VOICE_D_*` clips in /// `\etc\`, and many bind to nothing at all. Returns `None` when the movie /// has no voice-over (so the caller plays silence instead of guessing a bank). #[cfg(not(target_arch = "wasm32"))] fn resolve_movie_voice_clip( source: &SourceKind, movie: &str, lang: sylpheed_formats::slb::VoiceLang, ) -> Option { use sylpheed_formats::movie_manifest; let pak = read_pak_archive_blocking(source, "dat/tables.pak").ok()?; // The manifest has no stable name, so find it by shape among the entries. let manifest = pak .entries() .iter() .find_map(|e| pak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))?; let sounds = pak .read_by_name(&format!("{}\\sounds.tbl", lang.code_pub()))? .ok()?; // Only the manifest's DIRECT bindings are trusted. Extending to unbound // resupply movies by shared demo line was verified WRONG (played the wrong // recording), so unbound movies stay unvoiced rather than play a guess. movie_manifest::resolve_voice_entry(&manifest, &sounds, movie, lang) } /// Handles a [`RequestVoice`]: decodes the cutscene voice off-thread and posts /// the temp WAV path back via the loader channel. The voice bank is resolved /// from the movie manifest, so movies the game leaves unvoiced (e.g. most /// `hokyu_*` resupply cutscenes) correctly post no audio rather than a wrong clip. #[cfg(not(target_arch = "wasm32"))] fn handle_voice_request( mut events: EventReader, iso_state: Res, channels: Res, ) { let Some(req) = events.read().last() else { return; }; let source = iso_state.source_kind.clone(); let sender = channels.sender.clone(); let (movie, lang, duration, generation) = (req.movie.clone(), req.lang, req.duration, req.generation); std::thread::spawn(move || { // The cutscene voices live in one continuous XMA stream, addressed by cue // byte-region (movie → sound-id → [trailer(N-1)..trailer(N)]); this gives // the CORRECT, complete voice for ADV / S / RT movies, including the many // cues that span two `.slb` chunks. Movies whose voice is not a `\Movie\` // bank (hokyu resupply → `\etc\` demo clips) resolve to no region and fall // through to the legacy per-clip decoder. A `\Movie\` token that failed // region resolution must NOT play its raw `.slb` — that off-by-one chunk is // the wrong track — so it stays silent rather than wrong. let wav = resolve_movie_voice_region(&source, &movie, lang) .and_then(|(s, e)| decode_voice_region(&source, s, e, duration).ok()) .or_else(|| { let clip = resolve_movie_voice_clip(&source, &movie, lang)?; if clip.contains("\\Movie\\") { return None; } decode_sound_clip(&source, &clip, duration).ok() }); info!("[voice] movie={movie:?} gen={generation} -> wav={wav:?}"); let _ = sender.send(IsoLoaderMsg::VoiceLoaded { movie, generation, wav, }); }); } /// Duration (seconds) of a PCM WAV from its `fmt`/`data` chunks. #[cfg(not(target_arch = "wasm32"))] fn wav_duration(path: &Path) -> Option { let b = std::fs::read(path).ok()?; let find = |tag: &[u8]| b.windows(4).position(|w| w == tag); let f = find(b"fmt ")?; let rate = u32::from_le_bytes([b[f + 12], b[f + 13], b[f + 14], b[f + 15]]); let byte_rate = u32::from_le_bytes([b[f + 16], b[f + 17], b[f + 18], b[f + 19]]); let d = find(b"data")?; let data_sz = u32::from_le_bytes([b[d + 4], b[d + 5], b[d + 6], b[d + 7]]); let br = if byte_rate > 0 { byte_rate } else { rate * 2 }; (br > 0).then(|| data_sz as f32 / br as f32) } /// Handles a [`RequestAudio`]: decodes a movie's voice full-length (no clamp) for /// standalone playback, posting the WAV + duration back via the loader channel. #[cfg(not(target_arch = "wasm32"))] fn handle_audio_request( mut events: EventReader, iso_state: Res, channels: Res, ) { let Some(req) = events.read().last() else { return; }; let source = iso_state.source_kind.clone(); let sender = channels.sender.clone(); let (clip, display, movie, generation) = ( req.clip.clone(), req.display.clone(), req.movie.clone(), req.generation, ); std::thread::spawn(move || { // For a movie, decode its full-length cutscene voice via the continuous // stream (same resolution as playback), falling back to a non-`\Movie\` // clip (hokyu `\etc\`); for a named library clip, decode it directly. let wav = match movie { Some((m, lang)) => resolve_movie_voice_region(&source, &m, lang) .and_then(|(s, e)| decode_voice_region(&source, s, e, f32::INFINITY).ok()) .or_else(|| { let c = resolve_movie_voice_clip(&source, &m, lang)?; if c.contains("\\Movie\\") { return None; } decode_sound_clip(&source, &c, f32::INFINITY).ok() }), None => decode_sound_clip(&source, &clip, f32::INFINITY).ok(), } .and_then(|p| wav_duration(&p).map(|d| (p, d))); let _ = sender.send(IsoLoaderMsg::AudioLoaded { name: display, generation, wav, }); }); } /// Handles a [`RequestGameData`]: decodes the combat / mission / cast tables /// off-thread and posts the snapshot back for the browser. #[cfg(not(target_arch = "wasm32"))] fn handle_game_data_request( mut events: EventReader, iso_state: Res, channels: Res, ) { if events.read().next().is_none() { return; } let source = iso_state.source_kind.clone(); let sender = channels.sender.clone(); std::thread::spawn(move || { let snap = build_game_snapshot(&source).unwrap_or_default(); let _ = sender.send(IsoLoaderMsg::GameDataLoaded(Box::new(snap))); }); } /// Decode the game-data tables into a browser snapshot (English pak + hangar pak). #[cfg(not(target_arch = "wasm32"))] fn build_game_snapshot(source: &SourceKind) -> Option { use sylpheed_formats::{game_data as gd, localization::TextIndex}; let main = read_pak_archive_blocking(source, "dat/GP_MAIN_GAME_E.pak").ok()?; let text = TextIndex::build(&main); let mut weapons = gd::load_weapons(&main); weapons.sort_by(|a, b| a.id.cmp(&b.id)); let mut craft = gd::load_units(&main); craft.sort_by(|a, b| a.id.cmp(&b.id)); let mut vessels = gd::load_vessels(&main); vessels.sort_by(|a, b| a.id.cmp(&b.id)); let mut characters: Vec = gd::load_characters(&main) .into_iter() .filter_map(|c| { let id = c.id?; let short = id.trim_start_matches("Character").to_string(); let name = text.character_name(&short).unwrap_or(&short).to_string(); Some(CharRow { name, faction: c.faction.unwrap_or_default(), faces: c.faces.len() }) }) .collect(); characters.sort_by(|a, b| (a.faction.clone(), a.name.clone()).cmp(&(b.faction.clone(), b.name.clone()))); // Combat rosters, keyed by stage where the table self-identifies. let rosters = gd::load_unit_rosters(&main); let stat_hp = |id: &str| -> Option { craft.iter().find(|u| u.id.as_deref() == Some(id)).and_then(|u| u.hp) .or_else(|| vessels.iter().find(|v| v.id.as_deref() == Some(id)).and_then(|v| v.hp)) }; let mut missions: Vec = Vec::new(); for s in gd::load_stages(&main) { // main + extra story stages only (skip the Test stage) if s.id.len() != 3 || !s.id.starts_with('S') || s.id[1..].parse::().is_err() { continue; } let objectives: Vec = (1..=s.phases).flat_map(|p| text.objectives(&s.id, p)).map(str::to_string).collect(); let lose: Vec = (1..=s.phases).flat_map(|p| text.lose_conditions(&s.id, p)).map(str::to_string).collect(); let enemies: Vec = rosters .iter() .find(|r| r.stage.as_deref() == Some(s.id.as_str())) .map(|r| { r.units.iter().filter(|u| u.contains("ADAN")).map(|u| { let name = u.trim_start_matches("UN_").splitn(3, '_').nth(2).unwrap_or(u).replace('_', " "); match stat_hp(u) { Some(h) => format!("{name} ({h:.0} HP)"), None => name, } }).collect() }) .unwrap_or_default(); missions.push(MissionRow { id: s.id.clone(), location: s.location.unwrap_or_default().replace('_', " "), phases: s.phases, objectives, lose, enemies, }); } missions.sort_by(|a, b| a.id.cmp(&b.id)); // Arsenal + flights from the hangar pak (optional). let (arsenal, flights) = read_pak_archive_blocking(source, "dat/GP_HANGAR_ARSENAL.pak") .map(|h| { let arsenal = gd::load_arsenal(&h); let mut seen = std::collections::BTreeSet::new(); let mut flights = Vec::new(); for r in gd::load_pilot_rosters(&h) { let key: String = r.pilots.iter().map(|(c, p)| format!("{c}:{p}")).collect::>().join(","); if seen.insert(key) { flights.push(r.pilots); } } (arsenal, flights) }) .unwrap_or_default(); Some(GameSnapshot { weapons, craft, vessels, characters, missions, arsenal, flights }) } /// Handles a [`RequestShipCatalog`]: scans the stage containers and reconstructs /// the assemblable ship catalog off-thread. #[cfg(not(target_arch = "wasm32"))] fn handle_ship_catalog_request( mut events: EventReader, iso_state: Res, channels: Res, mut ships: ResMut, ) { if events.read().next().is_none() { return; } if ships.loaded || ships.loading { return; // already built / building } ships.loading = true; let source = iso_state.source_kind.clone(); let sender = channels.sender.clone(); std::thread::spawn(move || { let rows = build_ship_catalog(&source); let _ = sender.send(IsoLoaderMsg::ShipCatalogLoaded(rows)); }); } /// Scan every `Stage_SNN.xpr` container, group XBG7 resources into whole ships, /// and join each to its [`sylpheed_formats::game_data::Vessel`] stats. #[cfg(not(target_arch = "wasm32"))] fn build_ship_catalog(source: &SourceKind) -> Vec { use sylpheed_formats::game_data::{self as gd, Vessel}; use sylpheed_formats::ship::ships_in_container; use std::collections::BTreeMap; // Vessel stats keyed by family id (`rou_e105` → `e105`). let vessels: Vec = read_pak_archive_blocking(source, "dat/GP_MAIN_GAME_E.pak") .map(|p| gd::load_vessels(&p)) .unwrap_or_default(); let vmap: BTreeMap = vessels .iter() .filter_map(|v| { let id = v.model.as_deref()?.trim_start_matches("rou_").to_string(); Some((id, v)) }) .collect(); // A capital-ship-ish display name from the Vessel id (`UN_e105_ADAN_Cruiser` // → `Cruiser`), else the family id. let pretty = |id: &str, v: Option<&&Vessel>| -> String { if let Some(v) = v { if let Some(vid) = &v.id { // UN___ if let Some(rest) = vid.strip_prefix("UN_") { let parts: Vec<&str> = rest.splitn(3, '_').collect(); if parts.len() == 3 { return parts[2].replace('_', " "); } } } } id.to_string() }; let mut agg: BTreeMap = BTreeMap::new(); for n in 1..=30u32 { let file = format!("hidden/resource3d/Stage_S{n:02}.xpr"); let Ok(bytes) = read_source_file(source, &file) else { continue; }; let label = format!("S{n:02}"); for sm in ships_in_container(&bytes) { // Skip single-piece props / debris — not "whole ships". if sm.parts.len() < 2 { continue; } let v = vmap.get(&sm.id); let entry = agg.entry(sm.id.clone()).or_insert_with(|| ShipRow { id: sm.id.clone(), faction: sm.faction.label().to_string(), name: pretty(&sm.id, v), stage_file: file.clone(), stages: Vec::new(), parts: sm.parts.clone(), has_vessel: v.is_some(), hp: v.and_then(|v| v.hp), size: v.and_then(|v| Some((v.size_x?, v.size_y?, v.size_z?))), turrets: v.and_then(|v| v.turret_count), bridges: v.and_then(|v| v.bridge_count), shield_gens: v.and_then(|v| v.shield_generator_count), }); if !entry.stages.contains(&label) { entry.stages.push(label.clone()); } } } let mut rows: Vec = agg.into_values().collect(); // Real capital ships (Vessel-matched) first, then by faction, then id. rows.sort_by(|a, b| { b.has_vessel .cmp(&a.has_vessel) .then(a.faction.cmp(&b.faction)) .then(a.id.cmp(&b.id)) }); rows } /// Handles a [`RequestShipRender`]: assembles the ship's part family into one /// world-space model off-thread and posts it through the shared XPR render path. #[cfg(not(target_arch = "wasm32"))] fn handle_ship_render_request( mut events: EventReader, iso_state: Res, channels: Res, mut xpr_load: ResMut, mut browser: ResMut, ) { let Some(req) = events.read().last() else { return; }; let (file, id, external, label) = (req.file.clone(), req.id.clone(), req.external, req.label.clone()); // Supersede any in-flight XPR/stage decode, exactly like a file selection. xpr_load.generation = xpr_load.generation.wrapping_add(1); if let Some(prev) = xpr_load.cancel.take() { prev.store(true, Ordering::Relaxed); } let cancel = Arc::new(AtomicBool::new(false)); xpr_load.cancel = Some(cancel.clone()); let generation = xpr_load.generation; browser.loading = true; let source = iso_state.source_kind.clone(); let sender = channels.sender.clone(); std::thread::spawn(move || { let prepared = build_ship_model(&source, &file, &id, external, &label, &cancel); let _ = sender.send(IsoLoaderMsg::XprPrepared { generation, prepared: Box::new(prepared), }); }); } /// Read a stage container, resolve the ship's scene-graph placement, decode only /// the referenced parts, transform each into its ship-local pose, and assemble /// them into a single world-space model (reusing the XPR model prepare path). #[cfg(not(target_arch = "wasm32"))] fn build_ship_model( source: &SourceKind, file: &str, id: &str, external: bool, label: &str, cancel: &Arc, ) -> PreparedXpr { use sylpheed_formats::mesh::Xbg7Model; let bytes = match read_source_file(source, file) { Ok(b) => b, Err(e) => return PreparedXpr::Info(format!("Could not read {file}: {e}")), }; let should_cancel = || cancel.load(Ordering::Relaxed); // Scene-graph placement: which resources to draw and where (bridge fore, // engines aft, hardpoints on their frames). let placed = sylpheed_formats::ship::assemble_ship(&bytes, id, external); if placed.is_empty() { return PreparedXpr::Info(format!("No parts found for {label}.")); } // Decode each distinct referenced resource once. let wanted: std::collections::HashSet = placed.iter().map(|p| p.resource.clone()).collect(); let base = Xbg7Model::models_named(&bytes, &wanted, &should_cancel); if should_cancel() { return PreparedXpr::Cancelled; } if base.is_empty() { return PreparedXpr::Info(format!("No geometry decoded for {label}.")); } // One transformed model per placement: bake the part's world pose into its // vertices (positions by the full affine, normals by the rotation) so the // shared prepare path can draw it in place. A resource placed at several // hardpoints yields several posed copies. The name is kept so `material_groups` // still finds the part's textures. let rot = |m: &[[f32; 3]; 3], n: &[f32; 3]| { [ m[0][0] * n[0] + m[0][1] * n[1] + m[0][2] * n[2], m[1][0] * n[0] + m[1][1] * n[1] + m[1][2] * n[2], m[2][0] * n[0] + m[2][1] * n[1] + m[2][2] * n[2], ] }; let mut models: Vec = Vec::with_capacity(placed.len()); for p in &placed { let Some(src) = base.iter().find(|m| m.name == p.resource) else { continue; }; let mut m = src.clone(); for sub in &mut m.meshes { for v in &mut sub.positions { *v = p.apply(*v); } for nrm in &mut sub.normals { *nrm = rot(&p.m, nrm); } } models.push(m); } if models.is_empty() { return PreparedXpr::Info(format!("No geometry decoded for {label}.")); } // Assemble, then relabel with the ship's display name. match prepare_ship(&bytes, &models) { PreparedXpr::Model { meshes, materials, focus, radius, min_radius, max_radius, info, .. } => PreparedXpr::Model { meshes, materials, focus, radius, min_radius, max_radius, name: label.to_string(), info, }, other => other, } } /// Handles a [`RequestVoiceLibrary`]: reads `tables.pak`'s `sounds.tbl` and /// enumerates the voice clips off-thread. #[cfg(not(target_arch = "wasm32"))] fn handle_voice_library_request( mut events: EventReader, iso_state: Res, channels: Res, ) { if events.read().next().is_none() { return; } let source = iso_state.source_kind.clone(); let sender = channels.sender.clone(); std::thread::spawn(move || { let clips = (|| -> Result, String> { let pak = read_pak_archive_blocking(&source, "dat/tables.pak")?; let tbl = pak .read_by_name("eng\\sounds.tbl") .ok_or("sounds.tbl not found")? .map_err(|e| e.to_string())?; Ok(sylpheed_formats::slb::list_voice_clips( &tbl, sylpheed_formats::slb::VoiceLang::English, )) })() .unwrap_or_default(); let _ = sender.send(IsoLoaderMsg::VoiceLibraryLoaded { clips }); }); } /// Drives the standalone audio player: builds/rebuilds its sink, mirrors /// play/pause + volume + seeks, advances the clock, and stops at the end. #[cfg(not(target_arch = "wasm32"))] fn advance_audio_playback( mut audio: ResMut, mut engine: NonSendMut, time: Res