feat(viewer): ISO / extracted-dir loading pipeline + native file dialog
Some checks failed
CI / Native — ubuntu-latest (push) Failing after 7m14s
CI / WASM — Web (push) Failing after 6m35s
CI / Formatting (push) Failing after 35s
CI / Native — macos-latest (push) Has been cancelled
CI / Native — windows-latest (push) Has been cancelled

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:
MechaCat02
2026-07-10 07:18:45 +02:00
parent ce9fe08bec
commit d92c7ddaea
7 changed files with 1111 additions and 61 deletions

View File

@@ -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 }

View File

@@ -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
}
})

View 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;
}

View File

@@ -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);

View File

@@ -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| {