Files
Syplheed-Reborn/crates/sylpheed-viewer/src/iso_loader.rs
MechaCat02 79c78cc7e1 ship: apply scale + gate approximate external parts behind a toggle
Two changes after user feedback that externals sit near-but-wrong (shield inset
into the hull, thruster floating close):

- Scale: the joint table's slots 5–7 are per-axis scale (a GN_ShieldG frame ships
  at 0.5, GN_Jet FX at 2.4–6.5) and were being ignored, rendering scaled parts at
  the wrong size. ScenePart now carries `s` and applies R·(S·v)+T.
- External placement is fundamentally approximate: a GN_* frame is the mount
  PIVOT (f105's two GN_ShieldG frames are both on the centreline), while the
  part's outboard/rotational offset lives in a detail sub-rig / runtime code this
  static pass can't recover. So assemble_ship gains `include_external`: the hull
  tier (rou_ nodes) is exact and always drawn; the external tier (bridge / shield
  / engine at GN_ frames) is opt-in via a "Show external parts" checkbox in the
  Ships browser, off by default so the clean, correct hull is the default view.

The module doc is corrected to the scene-graph mechanism (the old "draw parts
untransformed" claim was wrong).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:11:47 +02:00

4489 lines
169 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<IsoLoaderMsg>
//! ↓ (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<String>,
/// True while a background thread is loading.
pub loading: bool,
/// The most recent error message (cleared when a new operation starts).
pub error: Option<String>,
/// 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<Handle<Image>>,
/// egui texture ID registered via `EguiContexts::add_image`.
pub egui_id: Option<egui::TextureId>,
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<FileFormat>,
}
/// 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<String>,
/// 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<PakDetail>,
/// 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<ImageRgba>,
},
/// 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<ImageRgba>),
/// A RATC bundle — its listed children (T8aD children carry a decoded image).
Ratc(Vec<RatcEntry>),
/// 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<ImageRgba>,
}
/// 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<u8>,
}
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<Image>,
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<FaceTex>,
/// 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<Handle<Image>>,
pub egui_id: Option<egui::TextureId>,
/// 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<f32>,
/// 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<f32>,
/// 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<sylpheed_formats::SubCue>,
/// Basename (no extension) of the movie the cues belong to.
pub movie: Option<String>,
/// 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
/// 27 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<String>,
/// 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<PathBuf>,
}
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<f32>,
/// 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<sylpheed_formats::slb::VoiceClip>,
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<String>,
pub lose: Vec<String>,
pub enemies: Vec<String>,
}
/// Everything the Game Data browser shows, decoded off-thread from the paks.
#[derive(Default)]
pub struct GameSnapshot {
pub weapons: Vec<sylpheed_formats::game_data::Weapon>,
pub craft: Vec<sylpheed_formats::game_data::CraftUnit>,
pub vessels: Vec<sylpheed_formats::game_data::Vessel>,
pub characters: Vec<CharRow>,
pub missions: Vec<MissionRow>,
pub arsenal: sylpheed_formats::game_data::Arsenal,
/// Distinct flight line-ups: `(callsign, pilot)` rows.
pub flights: Vec<Vec<(String, String)>>,
}
/// 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<String>,
/// Base part resource names to draw together.
pub parts: Vec<String>,
/// True when the id matched a `Vessel` recipe (a real capital ship).
pub has_vessel: bool,
pub hp: Option<f32>,
pub size: Option<(f32, f32, f32)>,
pub turrets: Option<i64>,
pub bridges: Option<i64>,
pub shield_gens: Option<i64>,
}
/// 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<ShipRow>,
/// Family id of the ship currently rendered (for row highlight).
pub selected: Option<String>,
/// 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<PakRow>,
pub selected: Option<usize>,
pub error: Option<String>,
/// 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<egui::TextureHandle>)>,
}
/// 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<String>,
},
FileLoaded {
path: String,
bytes: Vec<u8>,
},
/// A `.pak` archive assembled + labelled off-thread (`error` set on failure).
PakLoaded {
name: String,
rows: Vec<PakRow>,
error: Option<String>,
},
/// 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<PreparedVideo>),
/// 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<PreparedXpr>,
},
/// 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<sylpheed_formats::SubCue>,
},
/// 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<PathBuf>,
},
/// 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<sylpheed_formats::slb::VoiceClip>,
},
/// The assembled-ship catalog for the Ships browser.
ShipCatalogLoaded(Vec<ShipRow>),
/// The decoded game-data tables for the browser.
GameDataLoaded(Box<GameSnapshot>),
/// 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<Image>,
pub metallic_roughness: Option<Image>,
pub emissive: Option<Image>,
}
#[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<PreparedMat>,
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<PathBuf>,
width: u32,
height: u32,
fps: f32,
duration: f32,
}
/// Channel resource. `Receiver<T>` 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<IsoLoaderMsg>,
receiver: Mutex<mpsc::Receiver<IsoLoaderMsg>>,
}
#[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<u8>,
}
/// 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<rodio::OutputStream>,
handle: Option<rodio::OutputStreamHandle>,
sink: Option<rodio::Sink>,
wav: Option<PathBuf>,
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<PakRow>,
error: Option<String>,
}
/// 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<Box<PreparedVideo>>,
}
/// 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<std::sync::Arc<std::sync::atomic::AtomicBool>>,
}
/// 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<Box<PreparedXpr>>,
}
/// 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<VideoEngineInner>,
}
#[cfg(not(target_arch = "wasm32"))]
struct VideoEngineInner {
/// Decoded RGBA frames `(pts_seconds, bytes)` from the ffmpeg reader thread.
frames: mpsc::Receiver<(f32, Vec<u8>)>,
/// The running `ffmpeg … -f rawvideo` child (killed on seek/teardown).
child: Child,
width: u32,
height: u32,
fps: f32,
temp_video: PathBuf,
temp_wav: Option<PathBuf>,
/// 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<u8>)>,
/// Desired scrub positions → one-shot grabber thread (coalesced to latest).
scrub_tx: mpsc::Sender<f32>,
/// Grabbed scrub frames back from the grabber thread.
scrub_frame_rx: mpsc::Receiver<Vec<u8>>,
/// 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<rodio::OutputStream>,
stream_handle: Option<rodio::OutputStreamHandle>,
sink: Option<rodio::Sink>,
/// 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 `<lang>\Movie\VOICE_<movie>.slb`), if any.
voice_wav: Option<PathBuf>,
/// 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<rodio::Sink>,
/// 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::<RequestOpenIso>()
.add_event::<RequestOpenDir>()
.add_event::<FileSelected>()
.init_resource::<IsoState>()
.init_resource::<TexturePreview>()
.init_resource::<FileInfo>()
// Registered unconditionally (even on wasm, where the load systems
// below are cfg'd out) so the UI can always read them.
.init_resource::<TextPreview>()
.init_resource::<PakView>()
.init_resource::<SkyboxPreview>()
.init_resource::<ModelPreview>()
.init_resource::<VideoPreview>()
.init_resource::<MovieSubtitles>()
.init_resource::<MovieVoice>()
.init_resource::<AudioPreview>()
.init_resource::<VoiceLibrary>()
.init_resource::<GameData>()
.init_resource::<ShipBrowser>()
.add_event::<RequestSubtitles>()
.add_event::<RequestVoice>()
.add_event::<RequestAudio>()
.add_event::<RequestVoiceLibrary>()
.add_event::<RequestGameData>()
.add_event::<RequestShipCatalog>()
.add_event::<RequestShipRender>();
#[cfg(not(target_arch = "wasm32"))]
{
app.init_resource::<IsoChannels>()
.init_resource::<PendingFileBytes>()
.init_resource::<PendingPak>()
.init_resource::<PendingVideo>()
.init_resource::<XprLoadState>()
.init_resource::<PendingXpr>()
.init_non_send_resource::<VideoEngine>()
.init_non_send_resource::<AudioEngine>()
.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<RequestOpenIso>,
mut iso_state: ResMut<IsoState>,
channels: Res<IsoChannels>,
) {
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<RequestOpenDir>,
mut iso_state: ResMut<IsoState>,
channels: Res<IsoChannels>,
) {
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<FileSelected>,
iso_state: Res<IsoState>,
channels: Res<IsoChannels>,
) {
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<u8>) -> Result<Vec<PakRow>, 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<ImageRgba> = 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<RatcEntry> = 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"<?xml") || vfs::identify_format(payload) == vfs::FileFormat::Text {
let (mut text, encoding) = vfs::decode_text(payload);
if text.len() > 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<ImageRgba> {
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<u8>) -> Result<PreparedVideo, String> {
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<std::process::Output, String> {
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::<f32>().unwrap_or(0.0), d.parse::<f32>().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<u8>)>), 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<u8>)>(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<rodio::Sink> {
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<Vec<u8>> {
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<f32>,
tx: mpsc::Sender<Vec<u8>>,
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<IsoChannels>,
mut iso_state: ResMut<IsoState>,
mut browser: ResMut<FileBrowserState>,
mut pending: ResMut<PendingFileBytes>,
mut pending_pak: ResMut<PendingPak>,
mut pending_video: ResMut<PendingVideo>,
mut pending_xpr: ResMut<PendingXpr>,
mut subtitles: ResMut<MovieSubtitles>,
mut mvoice: ResMut<MovieVoice>,
mut audio_preview: ResMut<AudioPreview>,
mut voice_lib: ResMut<VoiceLibrary>,
mut game_data: ResMut<GameData>,
mut ships: ResMut<ShipBrowser>,
) {
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<Image>,
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<Image>,
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<Image>,
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
/// `<letters><digits>` 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 <letters><digits> 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 `<stem>_col`, else shortest `<stem>…` 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<usize> {
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 `<stem>_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<PendingFileBytes>,
mut preview: ResMut<TexturePreview>,
mut text_preview: ResMut<TextPreview>,
mut pak_view: ResMut<PakView>,
mut skybox: ResMut<SkyboxPreview>,
mut video: ResMut<VideoPreview>,
mut engine: NonSendMut<VideoEngine>,
mut file_info: ResMut<FileInfo>,
mut images: ResMut<Assets<Image>>,
mut contexts: bevy_egui::EguiContexts,
mut commands: Commands,
mut model_preview: ResMut<ModelPreview>,
old_models: Query<Entity, With<PreviewModel>>,
channels: Res<IsoChannels>,
mut xpr_load: ResMut<XprLoadState>,
mut browser: ResMut<FileBrowserState>,
) {
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<AtomicBool>) -> 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<Xbg7Model> = 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<Image> {
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<PreparedMat> = vec![PreparedMat::default()]; // slot 0 = neutral tint
let mut tex_slot: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
// Decode a TX2D to a Bevy image, by index or by name.
let load_idx = |ti: usize| -> Option<Image> {
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<Image> {
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<PreparedMat>,
tex_slot: &mut std::collections::HashMap<usize, usize>|
-> 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<u32>,
}
let mut buffers: Vec<Buf> = vec![Buf {
pos: vec![],
nrm: vec![],
uv: vec![],
idx: vec![],
}];
let ensure = |buffers: &mut Vec<Buf>, 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<usize>)> = 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<Option<&sylpheed_formats::mesh::NodePlacement>> = {
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<usize>)> {
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<usize> = (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<usize> = (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<PendingXpr>,
xpr_load: Res<XprLoadState>,
mut browser: ResMut<FileBrowserState>,
mut preview: ResMut<TexturePreview>,
mut skybox: ResMut<SkyboxPreview>,
mut images: ResMut<Assets<Image>>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut contexts: bevy_egui::EguiContexts,
mut commands: Commands,
mut model_preview: ResMut<ModelPreview>,
old_models: Query<Entity, With<PreviewModel>>,
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<Handle<StandardMaterial>> = 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.61.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<PendingPak>,
mut pak_view: ResMut<PakView>,
mut preview: ResMut<TexturePreview>,
mut text_preview: ResMut<TextPreview>,
mut skybox: ResMut<SkyboxPreview>,
mut video: ResMut<VideoPreview>,
mut engine: NonSendMut<VideoEngine>,
mut file_info: ResMut<FileInfo>,
mut images: ResMut<Assets<Image>>,
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<PendingVideo>,
mut video: ResMut<VideoPreview>,
mut engine: NonSendMut<VideoEngine>,
mut preview: ResMut<TexturePreview>,
mut text_preview: ResMut<TextPreview>,
mut pak_view: ResMut<PakView>,
mut skybox: ResMut<SkyboxPreview>,
mut file_info: ResMut<FileInfo>,
mut images: ResMut<Assets<Image>>,
mut contexts: bevy_egui::EguiContexts,
mut subtitles: ResMut<MovieSubtitles>,
mut sub_events: EventWriter<RequestSubtitles>,
mut mvoice: ResMut<MovieVoice>,
mut voice_events: EventWriter<RequestVoice>,
mut audio_preview: ResMut<AudioPreview>,
) {
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::<f32>();
let (scrub_frame_tx, scrub_frame_rx) = mpsc::channel::<Vec<u8>>();
{
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<sylpheed_formats::PakArchive, String> {
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<RequestSubtitles>,
iso_state: Res<IsoState>,
channels: Res<IsoChannels>,
) {
// 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<Vec<sylpheed_formats::SubCue>, 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<Vec<u8>, 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
/// (`<base>.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<Vec<u8>, 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<Vec<u8>, 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<PathBuf, String> {
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<Vec<u8>>, tag: &str, duration: f32) -> Result<PathBuf, String> {
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::<String>(),
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<String> {
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 `<lang>\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(&registry).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<PathBuf, String> {
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
/// `<lang>\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<String> {
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<RequestVoice>,
iso_state: Res<IsoState>,
channels: Res<IsoChannels>,
) {
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<f32> {
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<RequestAudio>,
iso_state: Res<IsoState>,
channels: Res<IsoChannels>,
) {
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<RequestGameData>,
iso_state: Res<IsoState>,
channels: Res<IsoChannels>,
) {
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<GameSnapshot> {
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<CharRow> = 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<f32> {
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<MissionRow> = 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::<u32>().is_err() {
continue;
}
let objectives: Vec<String> =
(1..=s.phases).flat_map(|p| text.objectives(&s.id, p)).map(str::to_string).collect();
let lose: Vec<String> =
(1..=s.phases).flat_map(|p| text.lose_conditions(&s.id, p)).map(str::to_string).collect();
let enemies: Vec<String> = 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::<Vec<_>>().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<RequestShipCatalog>,
iso_state: Res<IsoState>,
channels: Res<IsoChannels>,
mut ships: ResMut<ShipBrowser>,
) {
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<ShipRow> {
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<Vessel> = read_pak_archive_blocking(source, "dat/GP_MAIN_GAME_E.pak")
.map(|p| gd::load_vessels(&p))
.unwrap_or_default();
let vmap: BTreeMap<String, &Vessel> = 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_<id>_<FACTION>_<Class…>
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<String, ShipRow> = 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<ShipRow> = 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<RequestShipRender>,
iso_state: Res<IsoState>,
channels: Res<IsoChannels>,
mut xpr_load: ResMut<XprLoadState>,
mut browser: ResMut<FileBrowserState>,
) {
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<AtomicBool>,
) -> 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<String> =
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<Xbg7Model> = 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<RequestVoiceLibrary>,
iso_state: Res<IsoState>,
channels: Res<IsoChannels>,
) {
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<Vec<sylpheed_formats::slb::VoiceClip>, 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<AudioPreview>,
mut engine: NonSendMut<AudioEngine>,
time: Res<Time>,
) {
// Build the sink for a freshly-decoded track.
if let Some((wav, dur)) = audio.pending.take() {
if engine.handle.is_none() {
if let Ok((s, h)) = rodio::OutputStream::try_default() {
engine._stream = Some(s);
engine.handle = Some(h);
}
}
if let Some(h) = &engine.handle {
engine.sink = build_audio_sink(h, &wav, 0.0, audio.volume, true);
engine.applied_volume = audio.volume;
engine.was_playing = true;
engine.wav = Some(wav);
}
audio.active = true;
audio.playing = true;
audio.position = 0.0;
audio.duration = dur;
audio.loading = false;
}
// When deactivated (a video opened / closed), stop the sink and clean up.
if !audio.active {
if engine.sink.take().is_some() {
engine._stream = None;
engine.handle = None;
if let Some(w) = engine.wav.take() {
let _ = std::fs::remove_file(w);
}
}
return;
}
if let Some(t) = audio.seek_request.take() {
let t = t.clamp(0.0, audio.duration);
if let (Some(h), Some(wav)) = (&engine.handle, &engine.wav) {
engine.sink = build_audio_sink(h, wav, t, audio.volume, audio.playing);
engine.applied_volume = audio.volume;
engine.was_playing = audio.playing;
}
audio.position = t;
}
if let Some(s) = &engine.sink {
if (audio.volume - engine.applied_volume).abs() > 0.001 {
s.set_volume(audio.volume);
engine.applied_volume = audio.volume;
}
}
if audio.playing != engine.was_playing {
if let Some(s) = &engine.sink {
if audio.playing {
s.play();
} else {
s.pause();
}
}
engine.was_playing = audio.playing;
}
if audio.playing {
audio.position += time.delta_secs();
if audio.duration > 0.0 && audio.position >= audio.duration {
audio.position = audio.duration;
audio.playing = false;
if let Some(s) = &engine.sink {
s.pause();
}
engine.was_playing = false;
}
}
}
/// Drives the playing video each frame: honours seek requests, mirrors
/// play/pause + volume to the audio sink, advances the wall-clock position, and
/// uploads the frame due at that position into the shared texture.
#[cfg(not(target_arch = "wasm32"))]
fn advance_video_playback(
mut video: ResMut<VideoPreview>,
mut engine: NonSendMut<VideoEngine>,
mut voice: ResMut<MovieVoice>,
time: Res<Time>,
mut images: ResMut<Assets<Image>>,
) {
if !video.active {
return;
}
let Some(inner) = engine.inner.as_mut() else {
return;
};
// ── Apply a freshly-decoded voice track: build its sink on the shared output
// stream, layered over the movie's own music+SFX. ──
if let Some(wav) = voice.pending_wav.take() {
if let Some(handle) = &inner.stream_handle {
let vol = if voice.enabled { video.volume } else { 0.0 };
info!("[voice] APPLY sink movie={:?} wav={wav:?} pos={}", voice.movie, video.position);
inner.voice_sink =
build_audio_sink(handle, &wav, video.position, vol, video.playing && !video.scrubbing);
inner.voice_applied_volume = vol;
inner.voice_wav = Some(wav);
}
}
// ── Commit seek: restart the decoder (and audio) at the requested position.
// The last grabbed scrub frame stays on screen (via `awaiting_seek_frame`)
// until the restarted stream produces its first frame — no black flash. ──
if let Some(target) = video.seek_request.take() {
let target = target.clamp(0.0, video.duration);
match spawn_video_decoder(&inner.temp_video, inner.width, inner.height, inner.fps, target) {
Ok((new_child, new_rx)) => {
let _ = inner.child.kill();
let _ = inner.child.wait();
inner.child = new_child;
inner.frames = new_rx; // old receiver dropped → old reader exits
inner.held = None;
inner.awaiting_seek_frame = true;
if let (Some(handle), Some(wav)) = (&inner.stream_handle, &inner.temp_wav) {
// Rebuild the sink at the new offset (old sink dropped = silenced).
inner.sink = build_audio_sink(handle, wav, target, video.volume, video.playing);
}
// Rebuild the voice sink at the same offset so it stays in sync.
if let (Some(handle), Some(wav)) = (&inner.stream_handle, &inner.voice_wav) {
let vol = if voice.enabled { video.volume } else { 0.0 };
inner.voice_sink = build_audio_sink(handle, wav, target, vol, video.playing);
inner.voice_applied_volume = vol;
}
inner.applied_volume = video.volume;
inner.was_playing = video.playing;
video.position = target;
}
Err(e) => error!("seek failed: {e}"),
}
}
// ── Live scrub: ask the grabber for the frame under the knob. ──
if let Some(target) = video.scrub_request.take() {
let _ = inner.scrub_tx.send(target.clamp(0.0, video.duration));
}
// Audio + clock are suspended while scrubbing so they don't fight the knob.
let effective_playing = video.playing && !video.scrubbing;
// ── Mirror volume + play/pause to the sink. ──
if let Some(sink) = &inner.sink {
if (video.volume - inner.applied_volume).abs() > 0.001 {
sink.set_volume(video.volume);
inner.applied_volume = video.volume;
}
}
// Voice sink volume follows the master, gated by the enable toggle (0 = off).
// Keeping it playing-but-silent preserves sync so toggling is instant.
if let Some(vs) = &inner.voice_sink {
let target_vol = if voice.enabled { video.volume } else { 0.0 };
if (target_vol - inner.voice_applied_volume).abs() > 0.001 {
vs.set_volume(target_vol);
inner.voice_applied_volume = target_vol;
}
}
if effective_playing != inner.was_playing {
if let Some(sink) = &inner.sink {
if effective_playing {
sink.play();
} else {
sink.pause();
}
}
if let Some(vs) = &inner.voice_sink {
if effective_playing {
vs.play();
} else {
vs.pause();
}
}
inner.was_playing = effective_playing;
}
// ── Advance the wall-clock position while playing (not while scrubbing). ──
if effective_playing {
video.position += time.delta_secs();
if video.duration > 0.0 && video.position >= video.duration {
video.position = video.duration;
video.playing = false;
if let Some(sink) = &inner.sink {
sink.pause();
}
if let Some(vs) = &inner.voice_sink {
vs.pause();
}
inner.was_playing = false;
}
}
// ── Frame display. ──
// Always drain the grabber, keeping only the latest frame it produced.
let mut scrub_frame: Option<Vec<u8>> = None;
while let Ok(f) = inner.scrub_frame_rx.try_recv() {
scrub_frame = Some(f);
}
// While dragging, the grabbed frame under the knob is what we show.
if video.scrubbing {
if let (Some(handle), Some(frame)) = (&video.handle, scrub_frame) {
if let Some(img) = images.get_mut(handle) {
img.data = frame;
}
}
return;
}
// Otherwise show the newest streamed frame due at `position` (never drop or
// reorder). If the stream hasn't caught up after a seek, bridge with the
// last grabbed frame so the target position is visible immediately.
let pos = video.position;
let mut current: Option<Vec<u8>> = None;
loop {
if let Some((pts, _)) = &inner.held {
if *pts > pos {
break; // still in the future — keep holding it
}
current = inner.held.take().map(|(_, buf)| buf);
continue;
}
match inner.frames.try_recv() {
Ok((pts, buf)) => {
if pts > pos {
inner.held = Some((pts, buf));
break;
}
current = Some(buf);
}
Err(_) => break, // empty (or decoder finished)
}
}
let frame = if let Some(f) = current {
inner.awaiting_seek_frame = false; // stream caught up
Some(f)
} else if inner.awaiting_seek_frame {
scrub_frame // bridge with the grabbed target frame
} else {
None
};
if let (Some(handle), Some(frame)) = (&video.handle, frame) {
if let Some(img) = images.get_mut(handle) {
img.data = frame;
}
}
}
#[cfg(test)]
mod albedo_match_tests {
use super::*;
fn cue(start: f32, end: Option<f32>, text: &str) -> sylpheed_formats::SubCue {
sylpheed_formats::SubCue { start, end, text: text.into() }
}
#[test]
fn reference_cue_hides_after_reading_time() {
// Single start-only cue (a resupply line): shown from its start for an
// estimated reading span, then hidden — NOT for the whole video.
let subs = MovieSubtitles {
cues: vec![cue(0.0, None, "Resupply complete. You are cleared for take-off!")],
..Default::default()
};
assert!(subs.active_at(0.0).is_some());
assert!(subs.active_at(3.0).is_some());
assert!(subs.active_at(30.0).is_none(), "must not linger the whole video");
}
#[test]
fn start_only_cues_have_gaps_and_dont_overlap() {
// Two far-apart radio lines: each visible near its own start, with a gap
// in between (the first doesn't stretch to the second).
let subs = MovieSubtitles {
cues: vec![cue(0.5, None, "We did it!"), cue(19.3, None, "Yeah, but Brandon...")],
..Default::default()
};
assert_eq!(subs.active_at(1.0).map(|c| c.text.as_str()), Some("We did it!"));
assert!(subs.active_at(12.0).is_none(), "gap between the two lines");
assert_eq!(subs.active_at(20.0).map(|c| c.text.as_str()), Some("Yeah, but Brandon..."));
}
#[test]
fn ranged_cue_uses_exact_end() {
// Inline narration with an explicit start-end range is honored exactly.
let subs = MovieSubtitles {
cues: vec![cue(2.2, Some(8.2), "It's the 27th century.")],
..Default::default()
};
assert!(subs.active_at(5.0).is_some());
assert!(subs.active_at(9.0).is_none());
}
#[test]
fn disabled_shows_nothing() {
let subs = MovieSubtitles {
enabled: false,
cues: vec![cue(0.0, Some(100.0), "x")],
..Default::default()
};
assert!(subs.active_at(1.0).is_none());
}
#[test]
fn entity_stem_strips_decoration() {
assert_eq!(entity_stem("e_rou_e007_Near"), "e007");
assert_eq!(entity_stem("e007_bdy_01_l"), "e007");
assert_eq!(entity_stem("f001_bdy_02"), "f001");
assert_eq!(entity_stem("f001"), "f001");
assert_eq!(entity_stem("g001"), "g001");
assert_eq!(entity_stem("_rou_f001_mnv01_L"), "f001");
}
#[test]
fn albedo_matches_entity_col_map() {
let texs: Vec<String> = ["e007_col", "e007_lum", "e007_spc", "e010_col"]
.iter()
.map(|s| s.to_string())
.collect();
// Sub-models decorated with LOD / part suffixes still map to `e007_col`,
// where the old `starts_with(full_name)` rule fell back to grey.
assert_eq!(pick_albedo_index("e_rou_e007_Near", &texs), Some(0));
assert_eq!(pick_albedo_index("e007_bdy_01_l", &texs), Some(0));
assert_eq!(pick_albedo_index("e010_bdy", &texs), Some(3));
// A hangar-suffixed col map is still recognised as a colour map.
let hangar = vec!["f001_wep_00_col_hangar".to_string()];
assert_eq!(pick_albedo_index("f001_wep_00_hangar", &hangar), Some(0));
// No matching entity map ⇒ None (caller shows a neutral tint).
assert_eq!(pick_albedo_index("z999", &texs), None);
}
#[test]
fn albedo_prefers_primary_body_map() {
// A multi-part ship body names several `_col` maps. The whole hull must
// pick the primary 1024² `…_bdy_01a_col`, NOT the shortest-named tiny
// part map (`f001_bdy_02_col`, 128²) the old rule chose → the pixelation
// fix. Directory order deliberately puts a small map first.
let texs: Vec<String> = [
"f001_bdy_02_col",
"f001_bdy_03_col",
"f001_bdy_01a_col",
"f001_bdy_01b_col",
"f001_bdy_daiza_col",
]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(pick_albedo_index("f001", &texs), Some(2)); // …_01a_col
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod pose_set_tests {
use super::*;
use sylpheed_formats::mesh::{GameMesh, Xbg7Model};
fn model(name: &str, verts: usize) -> Xbg7Model {
let sub = GameMesh {
positions: vec![[0.0, 0.0, 0.0]; verts],
indices: vec![0, 1, 2],
..Default::default()
};
Xbg7Model {
name: name.to_string(),
meshes: vec![sub],
}
}
#[test]
fn detects_ship_pose_set() {
// DeltaSaber_T layout: base `f001` + 4 `_mnv*`/`_turn180` poses, same
// vertex count → recognised as a single ship with 4 morph targets.
let models = vec![
model("f001", 100),
model("_rou_f001_mnv01_L", 100),
model("_rou_f001_mnv01_R", 100),
model("_rou_f001_mnv02", 100),
model("_rou_f001_turn180", 100),
];
let got = detect_pose_set(&models);
assert_eq!(got, Some((0, vec![1, 2, 3, 4])));
}
#[test]
fn rejects_multi_entity_stage() {
// Different stems = a real stage collection, not a pose set.
let models = vec![model("e007", 50), model("e010", 60), model("e011", 70)];
assert_eq!(detect_pose_set(&models), None);
}
#[test]
fn rejects_mismatched_topology() {
// A pose whose vertex count differs can't be blended → excluded; if none
// remain, it's not treated as a ship.
let models = vec![model("f001", 100), model("_rou_f001_mnv01_L", 99)];
assert_eq!(detect_pose_set(&models), None);
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod font_sample_tests {
use super::*;
use std::path::Path;
/// The real English movie font must rasterize a sample off-thread without
/// panicking (regression for the egui `set_fonts` crash).
#[test]
fn real_font_rasterizes() {
let pak = Path::new("/tmp/sylph_extract/dat/movie/eng.pak");
if !pak.exists() {
eprintln!("SKIP: extract not present");
return;
}
let arc = sylpheed_formats::PakArchive::open(pak).unwrap();
let bytes = arc.read_by_hash(0x5cd0_fca6).unwrap().unwrap();
assert!(sylpheed_formats::font::is_font(&bytes));
let img = render_font_sample(&bytes, "The quick brown fox 0123", 34.0)
.expect("real TrueType font should rasterize a sample");
assert!(img.width > 0 && img.height > 0);
assert_eq!(img.rgba.len(), (img.width * img.height * 4) as usize);
assert!(img.rgba.iter().skip(3).step_by(4).any(|&a| a > 0), "some ink");
}
}