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:
@@ -31,11 +31,14 @@ bevy = { workspace = true, features = [
|
||||
"bevy_winit",
|
||||
"multi_threaded",
|
||||
"png",
|
||||
# Required for TonyMcMapFace tonemapping (default Camera3d tonemapper)
|
||||
"tonemapping_luts",
|
||||
# Linux display backends
|
||||
"x11",
|
||||
"wayland",
|
||||
] }
|
||||
bevy_egui = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -46,3 +49,4 @@ dev = ["bevy/dynamic_linking"]
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tracing-subscriber = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
//! 4. Bevy uploads it to the GPU automatically
|
||||
|
||||
use bevy::asset::io::Reader;
|
||||
use bevy::asset::{AssetLoader, AsyncReadExt, LoadContext};
|
||||
use bevy::asset::{AssetLoader, LoadContext};
|
||||
use bevy::image::ImageSampler;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_asset::RenderAssetUsages;
|
||||
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
|
||||
use bevy::render::render_resource::{Extent3d, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages};
|
||||
use sylpheed_formats::texture::{X360Texture, X360TextureFormat};
|
||||
|
||||
// ── Plugin ────────────────────────────────────────────────────────────────────
|
||||
@@ -86,24 +87,32 @@ impl AssetLoader for Xpr2TextureLoader {
|
||||
|
||||
/// Convert a decoded `X360Texture` into a Bevy-compatible `Image`.
|
||||
///
|
||||
/// Maps Xbox 360 D3DFORMAT codes to `wgpu::TextureFormat` values.
|
||||
/// Bevy uses wgpu internally, so this mapping is direct.
|
||||
/// Constructs the `Image` struct directly rather than using `Image::new()`,
|
||||
/// because `Image::new()` calls `pixel_size()` which panics for BCn
|
||||
/// block-compressed formats.
|
||||
pub fn x360_texture_to_bevy_image(tex: X360Texture) -> Result<Image, Xpr2LoadError> {
|
||||
let bevy_format = x360_format_to_wgpu(&tex.format)?;
|
||||
let format = x360_format_to_wgpu(&tex.format)?;
|
||||
|
||||
Ok(Image::new(
|
||||
Extent3d {
|
||||
width: tex.width,
|
||||
height: tex.height,
|
||||
depth_or_array_layers: 1,
|
||||
Ok(Image {
|
||||
data: tex.data,
|
||||
texture_descriptor: TextureDescriptor {
|
||||
label: None,
|
||||
size: Extent3d {
|
||||
width: tex.width,
|
||||
height: tex.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: TextureDimension::D2,
|
||||
format,
|
||||
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
},
|
||||
TextureDimension::D2,
|
||||
tex.data,
|
||||
bevy_format,
|
||||
// Make the texture available on both CPU and GPU
|
||||
// (RenderOnly would be more efficient once RE is complete)
|
||||
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
|
||||
))
|
||||
sampler: ImageSampler::Default,
|
||||
texture_view_descriptor: None,
|
||||
asset_usage: RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
|
||||
})
|
||||
}
|
||||
|
||||
/// Map an `X360TextureFormat` to the corresponding `wgpu::TextureFormat`.
|
||||
@@ -119,14 +128,12 @@ fn x360_format_to_wgpu(format: &X360TextureFormat) -> Result<TextureFormat, Xpr2
|
||||
X360TextureFormat::Dxt3 => TextureFormat::Bc2RgbaUnormSrgb,
|
||||
// DXT5 / BC3
|
||||
X360TextureFormat::Dxt5 => TextureFormat::Bc3RgbaUnormSrgb,
|
||||
// DXN / BC5 — normal maps (RG, not sRGB)
|
||||
// DXN / BC5 — two-channel normal maps (RG, not sRGB)
|
||||
X360TextureFormat::Dxn => TextureFormat::Bc5RgUnorm,
|
||||
// Uncompressed ARGB — note Xbox 360 is BGRA order (big-endian)
|
||||
// We may need to swizzle R and B channels
|
||||
// DXT5A / BC4 — single-channel (gloss, specular, luminance maps)
|
||||
X360TextureFormat::Dxt5A => TextureFormat::Bc4RUnorm,
|
||||
// Uncompressed ARGB — Xbox 360 stores as BGRA in memory
|
||||
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
|
||||
// Xbox 360 stores as ARGB big-endian (BGRA in memory)
|
||||
// wgpu uses Rgba8UnormSrgb — swizzle may be needed
|
||||
// TODO: verify channel order against actual game textures
|
||||
TextureFormat::Bgra8UnormSrgb
|
||||
}
|
||||
})
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use bevy_egui::EguiPlugin;
|
||||
|
||||
pub mod asset_loader;
|
||||
pub mod camera;
|
||||
pub mod iso_loader;
|
||||
pub mod ui;
|
||||
|
||||
// ── Application state ────────────────────────────────────────────────────────
|
||||
@@ -75,6 +76,7 @@ pub fn run() {
|
||||
|
||||
app.add_plugins(EguiPlugin);
|
||||
app.add_plugins(asset_loader::SylpheedAssetPlugin);
|
||||
app.add_plugins(iso_loader::IsoLoaderPlugin);
|
||||
app.add_plugins(camera::OrbitCameraPlugin);
|
||||
app.add_plugins(ui::ViewerUiPlugin);
|
||||
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
//! egui-based debug UI for the asset viewer.
|
||||
//!
|
||||
//! Provides panels for:
|
||||
//! - File browser (list extracted game files)
|
||||
//! - File browser (list ISO / extracted game files, filter by name)
|
||||
//! - Texture inspector (preview + format info)
|
||||
//! - Mesh inspector (vertex/index counts, materials)
|
||||
//! - File info (size, detected format for non-texture files)
|
||||
//! - RE notes (track discoveries during reverse engineering)
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts};
|
||||
|
||||
use crate::iso_loader::{
|
||||
FileInfo, FileSelected, IsoState, RequestOpenDir, RequestOpenIso, TexturePreview,
|
||||
IsoLoaderSystemSet,
|
||||
};
|
||||
use crate::{ViewerState, ViewMode};
|
||||
|
||||
pub struct ViewerUiPlugin;
|
||||
|
||||
impl Plugin for ViewerUiPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Update, draw_viewer_ui);
|
||||
app.insert_resource(FileBrowserState::default());
|
||||
// Run after the iso_loader chain so we see the frame's final state.
|
||||
app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +37,12 @@ fn draw_viewer_ui(
|
||||
mut contexts: EguiContexts,
|
||||
mut viewer: ResMut<ViewerState>,
|
||||
mut browser: ResMut<FileBrowserState>,
|
||||
asset_server: Res<AssetServer>,
|
||||
iso_state: Res<IsoState>,
|
||||
preview: Res<TexturePreview>,
|
||||
file_info: Res<FileInfo>,
|
||||
mut open_iso_events: EventWriter<RequestOpenIso>,
|
||||
mut open_dir_events: EventWriter<RequestOpenDir>,
|
||||
mut file_selected_events: EventWriter<FileSelected>,
|
||||
) {
|
||||
let ctx = contexts.ctx_mut();
|
||||
|
||||
@@ -39,26 +50,45 @@ fn draw_viewer_ui(
|
||||
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
|
||||
egui::menu::bar(ui, |ui| {
|
||||
ui.menu_button("File", |ui| {
|
||||
if ui.button("Open extracted directory...").clicked() {
|
||||
// TODO: file dialog (rfd crate on native, not available on WASM)
|
||||
info!("TODO: open directory dialog");
|
||||
ui.close_menu();
|
||||
}
|
||||
ui.separator();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if ui.button("Quit").clicked() {
|
||||
std::process::exit(0);
|
||||
{
|
||||
if ui.button("Open ISO disc image…").clicked() {
|
||||
open_iso_events.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Open extracted folder…").clicked() {
|
||||
open_dir_events.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Quit").clicked() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
ui.colored_label(
|
||||
egui::Color32::GRAY,
|
||||
"File loading not available in browser",
|
||||
);
|
||||
});
|
||||
|
||||
ui.menu_button("View", |ui| {
|
||||
if ui.selectable_label(viewer.current_mode == ViewMode::Textures, "Textures").clicked() {
|
||||
if ui
|
||||
.selectable_label(viewer.current_mode == ViewMode::Textures, "Textures")
|
||||
.clicked()
|
||||
{
|
||||
viewer.current_mode = ViewMode::Textures;
|
||||
}
|
||||
if ui.selectable_label(viewer.current_mode == ViewMode::Mesh, "Meshes").clicked() {
|
||||
if ui
|
||||
.selectable_label(viewer.current_mode == ViewMode::Mesh, "Meshes")
|
||||
.clicked()
|
||||
{
|
||||
viewer.current_mode = ViewMode::Mesh;
|
||||
}
|
||||
if ui.selectable_label(viewer.current_mode == ViewMode::Audio, "Audio").clicked() {
|
||||
if ui
|
||||
.selectable_label(viewer.current_mode == ViewMode::Audio, "Audio")
|
||||
.clicked()
|
||||
{
|
||||
viewer.current_mode = ViewMode::Audio;
|
||||
}
|
||||
ui.separator();
|
||||
@@ -66,7 +96,7 @@ fn draw_viewer_ui(
|
||||
});
|
||||
|
||||
ui.menu_button("Help", |ui| {
|
||||
if ui.button("Controls...").clicked() {
|
||||
if ui.button("Controls…").clicked() {
|
||||
// TODO: controls popup
|
||||
}
|
||||
if ui.button("About").clicked() {
|
||||
@@ -93,29 +123,39 @@ fn draw_viewer_ui(
|
||||
|
||||
// File list
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
if browser.files.is_empty() {
|
||||
if iso_state.loading {
|
||||
ui.spinner();
|
||||
ui.label("Loading…");
|
||||
} else if let Some(err) = &iso_state.error {
|
||||
ui.colored_label(
|
||||
egui::Color32::RED,
|
||||
format!("⚠ Error:\n{}", err),
|
||||
);
|
||||
} else if browser.files.is_empty() {
|
||||
ui.colored_label(
|
||||
egui::Color32::YELLOW,
|
||||
"⚠ No files loaded.\nExtract your ISO first:\n\
|
||||
xdvdfs unpack game.iso ./assets/",
|
||||
"No files loaded.\nUse File → Open ISO disc image\n\
|
||||
or File → Open extracted folder.",
|
||||
);
|
||||
} else {
|
||||
let filter = browser.filter.to_lowercase();
|
||||
// Collect owned strings so we can later mutate `browser.selected`
|
||||
let filtered: Vec<(usize, String)> = browser.files
|
||||
let filtered: Vec<(usize, String)> = browser
|
||||
.files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, f)| filter.is_empty() || f.to_lowercase().contains(&filter))
|
||||
.filter(|(_, f)| {
|
||||
filter.is_empty() || f.to_lowercase().contains(&filter)
|
||||
})
|
||||
.map(|(i, f)| (i, f.clone()))
|
||||
.collect();
|
||||
|
||||
for (idx, file) in &filtered {
|
||||
let is_selected = browser.selected == Some(*idx);
|
||||
let label = egui::SelectableLabel::new(is_selected, file.as_str());
|
||||
let label =
|
||||
egui::SelectableLabel::new(is_selected, file.as_str());
|
||||
if ui.add(label).clicked() {
|
||||
browser.selected = Some(*idx);
|
||||
info!("Selected file: {}", file);
|
||||
// TODO: load the selected file into the viewer
|
||||
file_selected_events.send(FileSelected(file.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,31 +165,84 @@ fn draw_viewer_ui(
|
||||
// ── Bottom panel: status bar ──────────────────────────────────────────
|
||||
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(label) = &iso_state.source_label {
|
||||
ui.label(label);
|
||||
ui.separator();
|
||||
}
|
||||
match viewer.current_mode {
|
||||
ViewMode::Textures => ui.label("Mode: Texture Viewer"),
|
||||
ViewMode::Mesh => ui.label("Mode: Mesh Viewer"),
|
||||
ViewMode::Audio => ui.label("Mode: Audio Viewer"),
|
||||
ViewMode::Mesh => ui.label("Mode: Mesh Viewer"),
|
||||
ViewMode::Audio => ui.label("Mode: Audio Viewer"),
|
||||
};
|
||||
ui.separator();
|
||||
ui.label(format!("{} files loaded", browser.files.len()));
|
||||
ui.label(format!("{} files", browser.files.len()));
|
||||
ui.separator();
|
||||
ui.label("LMB drag: orbit | RMB drag: pan | Scroll: zoom | R: reset");
|
||||
ui.label("LMB: orbit | RMB: pan | Scroll: zoom | R: reset");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Central area: RE notes (shown when no asset is selected) ──────────
|
||||
if browser.selected.is_none() {
|
||||
// ── Central area ──────────────────────────────────────────────────────
|
||||
if browser.selected.is_some() {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
if let Some(egui_id) = preview.egui_id {
|
||||
// Texture preview
|
||||
ui.heading(&file_info.name);
|
||||
ui.label(&preview.format_info);
|
||||
if !preview.format_info.is_empty() && preview.format_info.contains("failed") {
|
||||
ui.colored_label(egui::Color32::YELLOW, &preview.format_info);
|
||||
}
|
||||
ui.separator();
|
||||
|
||||
// Scale to fit, preserving aspect ratio, never upscale
|
||||
let avail = ui.available_size();
|
||||
let scale = if preview.width == 0 || preview.height == 0 {
|
||||
1.0_f32
|
||||
} else {
|
||||
(avail.x / preview.width as f32)
|
||||
.min(avail.y / preview.height as f32)
|
||||
.min(1.0)
|
||||
};
|
||||
let display_w = preview.width as f32 * scale;
|
||||
let display_h = preview.height as f32 * scale;
|
||||
|
||||
ui.add(egui::Image::new(egui::load::SizedTexture::new(
|
||||
egui_id,
|
||||
[display_w, display_h],
|
||||
)));
|
||||
} else {
|
||||
// Non-texture file info
|
||||
ui.heading(&file_info.name);
|
||||
if !preview.format_info.is_empty() {
|
||||
ui.colored_label(egui::Color32::YELLOW, &preview.format_info);
|
||||
}
|
||||
if file_info.size_bytes > 0 {
|
||||
ui.separator();
|
||||
ui.label(format!("Size: {} bytes", file_info.size_bytes));
|
||||
if let Some(fmt) = file_info.detected_format {
|
||||
ui.label(format!("Detected format: {:?}", fmt));
|
||||
}
|
||||
} else if iso_state.loading {
|
||||
ui.spinner();
|
||||
ui.label("Loading file…");
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No file selected — show welcome / RE notes
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.heading("Project Sylpheed: Arc of Deception");
|
||||
ui.label("Asset Viewer — Milestone 1");
|
||||
ui.separator();
|
||||
|
||||
ui.collapsing("Getting Started", |ui| {
|
||||
ui.label("1. Dump your Project Sylpheed disc to an ISO");
|
||||
ui.label("Option A — open an ISO directly:");
|
||||
ui.code(" File → Open ISO disc image…");
|
||||
ui.separator();
|
||||
ui.label("Option B — extract first, then open folder:");
|
||||
ui.code(" xdvdfs unpack sylpheed.iso ./assets/");
|
||||
ui.label("2. Restart the viewer — files will appear in the left panel");
|
||||
ui.label("3. Click a .xpr file to preview the texture");
|
||||
ui.label("4. Use File → Open to select a different directory");
|
||||
ui.code(" File → Open extracted folder…");
|
||||
ui.separator();
|
||||
ui.label("Click a .xpr file in the left panel to preview the texture.");
|
||||
});
|
||||
|
||||
ui.collapsing("Reverse Engineering Notes", |ui| {
|
||||
|
||||
Reference in New Issue
Block a user