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>
4489 lines
169 KiB
Rust
4489 lines
169 KiB
Rust
//! 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
|
||
/// 2–7 s so a short line still lingers and a long one doesn't overstay.
|
||
fn estimated_read_secs(text: &str) -> f32 {
|
||
let chars = text.chars().filter(|c| !c.is_whitespace()).count() as f32;
|
||
(1.2 + chars / 13.0).clamp(2.0, 7.0)
|
||
}
|
||
|
||
/// Ask the loader to (re)load subtitles for `movie` in `lang`. Fired on video
|
||
/// open and on language change.
|
||
#[derive(Event)]
|
||
pub struct RequestSubtitles {
|
||
pub movie: String,
|
||
pub lang: sylpheed_formats::SubLang,
|
||
pub generation: u64,
|
||
}
|
||
|
||
/// The cutscene voice-over track for the current movie. The audio itself lives
|
||
/// in the video engine (a second rodio sink); this resource holds the UI toggle
|
||
/// and async-load bookkeeping. Registered on all platforms; the loader is
|
||
/// native-only.
|
||
#[derive(Resource)]
|
||
pub struct MovieVoice {
|
||
/// Whether the voice track is audible (user toggle).
|
||
pub enabled: bool,
|
||
/// Voice language (only English/Japanese exist on disc).
|
||
pub lang: sylpheed_formats::slb::VoiceLang,
|
||
/// Basename of the movie the current voice belongs to.
|
||
pub movie: Option<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.6–1.8).
|
||
emissive: if has_emissive {
|
||
LinearRgba::rgb(1.7, 1.7, 1.7)
|
||
} else {
|
||
LinearRgba::BLACK
|
||
},
|
||
emissive_texture,
|
||
cull_mode: None,
|
||
double_sided: true,
|
||
..default()
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
let root = commands
|
||
.spawn((
|
||
PreviewModel,
|
||
Transform::default(),
|
||
Visibility::default(),
|
||
Name::new(name.clone()),
|
||
))
|
||
.id();
|
||
for (mesh, mat) in prepared_meshes {
|
||
let material = mat_handles
|
||
.get(mat)
|
||
.cloned()
|
||
.unwrap_or_else(|| mat_handles[0].clone());
|
||
let child = commands
|
||
.spawn((
|
||
PreviewModel,
|
||
Mesh3d(meshes.add(mesh)),
|
||
MeshMaterial3d(material),
|
||
Transform::default(),
|
||
))
|
||
.id();
|
||
commands.entity(root).add_child(child);
|
||
}
|
||
|
||
if let Ok(mut cam) = orbit.get_single_mut() {
|
||
cam.focus = focus;
|
||
cam.radius = radius;
|
||
cam.min_radius = min_radius;
|
||
cam.max_radius = max_radius;
|
||
}
|
||
model_preview.active = true;
|
||
model_preview.name = name;
|
||
model_preview.info = info;
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
/// Consumes a staged pack, freeing the previous texture/text previews and
|
||
/// populating `PakView` for the master-detail browser.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn apply_pak(
|
||
mut pending: ResMut<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(®istry).get(&token)?;
|
||
// physical anchor: the TOC offset of this token's own `.slb` chunk — a start
|
||
// point near the cue's trailers (the cue itself may sit before/after it). The
|
||
// token's subdir varies: `Movie` (ADV/RT/S cutscenes) or `etc` (hokyu VOICE_D).
|
||
let stoc = read_source_file(source, "dat/sound.pak").ok()?;
|
||
let entries = PakArchive::parse_toc(&stoc).ok()?;
|
||
let anchor = ["Movie", "etc", "Voice"].iter().find_map(|dir| {
|
||
let h = name_hash(&format!("{code}\\{dir}\\{token}.slb"));
|
||
entries
|
||
.binary_search_by_key(&h, |e| e.name_hash)
|
||
.ok()
|
||
.map(|i| entries[i].offset as u64)
|
||
})?;
|
||
// Scan a window spanning both directions from the anchor for this cue's trailer
|
||
// and its predecessor. The window must cover the largest bank (ADV ≈ 3.6 MB).
|
||
let win_start = anchor.saturating_sub(2 * 1024 * 1024) & !3;
|
||
let window = read_segment_range(source, "dat/sound", win_start, 8 * 1024 * 1024).ok()?;
|
||
let end_local = movie_voice::find_descriptor(&window, id)?;
|
||
let end = win_start + end_local as u64;
|
||
// Cue start = its predecessor trailer. Prefer the exact `id-1` trailer; if the
|
||
// id sequence has a gap (e.g. VOICE_D_453→454), fall back to the nearest trailer
|
||
// below this one — but only if it's within one bank (~1.5 MB), else this is the
|
||
// first cue in its block and the audio starts at the bank anchor itself.
|
||
let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
|
||
.or_else(|| movie_voice::find_descriptor_before(&window, end_local))
|
||
.map(|o| win_start + o as u64)
|
||
.filter(|&s| s < end && end - s < 1_500_000)
|
||
.unwrap_or(anchor);
|
||
Some((start, end))
|
||
}
|
||
|
||
/// Decode a continuous movie-voice byte region (from [`resolve_movie_voice_region`])
|
||
/// into a mono WAV, clamped to `duration`.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn decode_voice_region(
|
||
source: &SourceKind,
|
||
start: u64,
|
||
end: u64,
|
||
duration: f32,
|
||
) -> Result<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");
|
||
}
|
||
}
|