feat(viewer): ISO / extracted-dir loading pipeline + native file dialog
Some checks failed
Some checks failed
WIP: add iso_loader.rs — bridges the async XisoReader / synchronous GameAssets to Bevy's ECS via background threads + mpsc channels polled per frame (no main-thread blocking); UI open-iso/open-dir/file-select events → texture preview. Register IsoLoaderPlugin; wire asset_loader and the egui UI. Deps: rfd (native file dialog, workspace + viewer), futures, and bevy tonemapping_luts feature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
458
crates/sylpheed-viewer/src/iso_loader.rs
Normal file
458
crates/sylpheed-viewer/src/iso_loader.rs
Normal file
@@ -0,0 +1,458 @@
|
||||
//! 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::{mpsc, Mutex};
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
// ── 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>,
|
||||
},
|
||||
/// User dismissed the file dialog — not an error.
|
||||
Cancelled,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
// ── 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>();
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
app.init_resource::<IsoChannels>()
|
||||
.init_resource::<PendingFileBytes>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
handle_open_iso,
|
||||
handle_open_dir,
|
||||
handle_file_selected,
|
||||
poll_loader_channel,
|
||||
apply_loaded_texture,
|
||||
)
|
||||
.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();
|
||||
|
||||
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 => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
) {
|
||||
let receiver = channels.receiver.lock().unwrap();
|
||||
loop {
|
||||
use std::sync::mpsc::TryRecvError;
|
||||
match receiver.try_recv() {
|
||||
Ok(IsoLoaderMsg::FilesListed {
|
||||
source,
|
||||
label,
|
||||
files,
|
||||
}) => {
|
||||
iso_state.loading = false;
|
||||
iso_state.error = None;
|
||||
iso_state.source_label = Some(label);
|
||||
iso_state.source_kind = source;
|
||||
browser.files = files;
|
||||
browser.selected = None;
|
||||
browser.filter.clear();
|
||||
info!("Loaded {} files", browser.files.len());
|
||||
}
|
||||
Ok(IsoLoaderMsg::FileLoaded { path, bytes }) => {
|
||||
pending.path = path;
|
||||
pending.bytes = bytes;
|
||||
pending.ready = true;
|
||||
}
|
||||
Ok(IsoLoaderMsg::Cancelled) => {
|
||||
iso_state.loading = false;
|
||||
}
|
||||
Ok(IsoLoaderMsg::Error(msg)) => {
|
||||
error!("ISO loader: {}", msg);
|
||||
iso_state.loading = false;
|
||||
iso_state.error = Some(msg);
|
||||
}
|
||||
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts pending raw bytes into a Bevy `Image`, registers it with egui,
|
||||
/// and populates `TexturePreview` / `FileInfo`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn apply_loaded_texture(
|
||||
mut pending: ResMut<PendingFileBytes>,
|
||||
mut preview: ResMut<TexturePreview>,
|
||||
mut file_info: ResMut<FileInfo>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
mut contexts: bevy_egui::EguiContexts,
|
||||
) {
|
||||
if !pending.ready {
|
||||
return;
|
||||
}
|
||||
pending.ready = false;
|
||||
|
||||
let bytes = std::mem::take(&mut pending.bytes);
|
||||
let path = std::mem::take(&mut pending.path);
|
||||
|
||||
let fmt = sylpheed_formats::vfs::identify_format(&bytes);
|
||||
|
||||
// Always free the previous texture first (GPU memory + egui slot).
|
||||
if let Some(ref handle) = preview.handle {
|
||||
contexts.remove_image(handle);
|
||||
images.remove(handle.id());
|
||||
}
|
||||
*preview = TexturePreview::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::Xpr2Texture {
|
||||
return; // Not a texture — FileInfo is sufficient
|
||||
}
|
||||
|
||||
let tex = match sylpheed_formats::X360Texture::from_xpr2(&bytes) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
preview.format_info = format!("XPR2 parse failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let w = tex.width;
|
||||
let h = tex.height;
|
||||
let mips = tex.mip_levels;
|
||||
let fmt_str = format!("{:?} {}×{} {} mip(s)", tex.format, w, h, mips);
|
||||
|
||||
let image = match crate::asset_loader::x360_texture_to_bevy_image(tex) {
|
||||
Ok(img) => img,
|
||||
Err(e) => {
|
||||
preview.format_info = format!("Bevy image conversion failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let handle = images.add(image);
|
||||
let egui_id = contexts.add_image(handle.clone_weak());
|
||||
|
||||
preview.handle = Some(handle);
|
||||
preview.egui_id = Some(egui_id);
|
||||
preview.width = w;
|
||||
preview.height = h;
|
||||
preview.format_info = fmt_str;
|
||||
}
|
||||
Reference in New Issue
Block a user