feat: initialise workspace — Milestone 1 asset explorer
Three-crate Cargo workspace structured per PROJECT.md spec: - crates/sylpheed-formats — Xbox 360 format parsers (no Bevy) - crates/sylpheed-viewer — Bevy 0.15 asset viewer + egui UI - crates/sylpheed-cli — CLI tools (extract/list/sniff/texture) Milestone 1 features: - XISO disc image reading via xdvdfs 0.8 - XPR2 texture container parsing + Morton de-tiling - D3DFORMAT → wgpu TextureFormat mapping (DXT1/3/5, DXN, ARGB) - Custom Bevy AssetLoader for .xpr files - Orbit camera (LMB orbit, RMB pan, scroll zoom) - egui file browser + RE notes panel - CLI: extract / list / sniff / texture info / texture export - GitHub Actions CI (Linux, macOS, Windows, WASM) - Trunk WASM build config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
154
crates/sylpheed-viewer/src/asset_loader.rs
Normal file
154
crates/sylpheed-viewer/src/asset_loader.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
//! Custom Bevy asset loaders for Project Sylpheed's file formats.
|
||||
//!
|
||||
//! Bevy's asset system is extended via `AssetLoader` implementations.
|
||||
//! Each loader converts a game-specific binary format into a Bevy `Image`
|
||||
//! or `Mesh` that can be used directly in the scene.
|
||||
//!
|
||||
//! ## How Bevy asset loading works
|
||||
//!
|
||||
//! 1. You request an asset: `asset_server.load("textures/ship01.xpr")`
|
||||
//! 2. Bevy looks for an `AssetLoader` that handles `.xpr` files
|
||||
//! 3. Our loader reads the bytes, de-tiles the texture, returns a `Image`
|
||||
//! 4. Bevy uploads it to the GPU automatically
|
||||
|
||||
use bevy::asset::io::Reader;
|
||||
use bevy::asset::{AssetLoader, AsyncReadExt, LoadContext};
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_asset::RenderAssetUsages;
|
||||
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
|
||||
use sylpheed_formats::texture::{X360Texture, X360TextureFormat};
|
||||
|
||||
// ── Plugin ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Registers all custom asset loaders with Bevy.
|
||||
pub struct SylpheedAssetPlugin;
|
||||
|
||||
impl Plugin for SylpheedAssetPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_asset_loader::<Xpr2TextureLoader>();
|
||||
// TODO: app.init_asset_loader::<SylpheedMeshLoader>();
|
||||
// TODO: app.init_asset_loader::<SylpheedAudioLoader>();
|
||||
info!("Sylpheed asset loaders registered");
|
||||
}
|
||||
}
|
||||
|
||||
// ── XPR2 Texture Loader ───────────────────────────────────────────────────────
|
||||
|
||||
/// Loads Xbox 360 XPR2 texture files (`.xpr`) as Bevy `Image` assets.
|
||||
///
|
||||
/// The loader:
|
||||
/// 1. Reads the raw XPR2 bytes
|
||||
/// 2. Parses the XPR2 header to find the texture descriptor
|
||||
/// 3. De-tiles the GPU memory layout using Morton-order de-interleaving
|
||||
/// 4. Returns a `bevy::render::texture::Image` with the correct `TextureFormat`
|
||||
#[derive(Default)]
|
||||
pub struct Xpr2TextureLoader;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Xpr2LoadError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Texture parse error: {0}")]
|
||||
Texture(#[from] sylpheed_formats::texture::TextureError),
|
||||
#[error("Unsupported texture format for Bevy upload")]
|
||||
UnsupportedForBevy,
|
||||
}
|
||||
|
||||
impl AssetLoader for Xpr2TextureLoader {
|
||||
type Asset = Image;
|
||||
type Settings = ();
|
||||
type Error = Xpr2LoadError;
|
||||
|
||||
async fn load(
|
||||
&self,
|
||||
reader: &mut dyn Reader,
|
||||
_settings: &Self::Settings,
|
||||
_load_context: &mut LoadContext<'_>,
|
||||
) -> Result<Self::Asset, Self::Error> {
|
||||
// Read all bytes from the asset source
|
||||
let mut bytes = Vec::new();
|
||||
reader.read_to_end(&mut bytes).await?;
|
||||
|
||||
// Parse the XPR2 container and de-tile the texture
|
||||
let x360_tex = X360Texture::from_xpr2(&bytes)?;
|
||||
|
||||
// Convert to Bevy's Image type
|
||||
x360_texture_to_bevy_image(x360_tex)
|
||||
}
|
||||
|
||||
fn extensions(&self) -> &[&str] {
|
||||
// Register for both .xpr and .xpr2 extensions
|
||||
&["xpr", "xpr2"]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Conversion: X360Texture → bevy::Image ─────────────────────────────────────
|
||||
|
||||
/// 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.
|
||||
pub fn x360_texture_to_bevy_image(tex: X360Texture) -> Result<Image, Xpr2LoadError> {
|
||||
let bevy_format = x360_format_to_wgpu(&tex.format)?;
|
||||
|
||||
Ok(Image::new(
|
||||
Extent3d {
|
||||
width: tex.width,
|
||||
height: tex.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
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,
|
||||
))
|
||||
}
|
||||
|
||||
/// Map an `X360TextureFormat` to the corresponding `wgpu::TextureFormat`.
|
||||
///
|
||||
/// Bevy fully supports BCn (DXT) block compression — the GPU handles
|
||||
/// decompression in hardware, so we pass DXT data directly without
|
||||
/// software decompression.
|
||||
fn x360_format_to_wgpu(format: &X360TextureFormat) -> Result<TextureFormat, Xpr2LoadError> {
|
||||
Ok(match format {
|
||||
// DXT1 / BC1
|
||||
X360TextureFormat::Dxt1 => TextureFormat::Bc1RgbaUnormSrgb,
|
||||
// DXT3 / BC2
|
||||
X360TextureFormat::Dxt3 => TextureFormat::Bc2RgbaUnormSrgb,
|
||||
// DXT5 / BC3
|
||||
X360TextureFormat::Dxt5 => TextureFormat::Bc3RgbaUnormSrgb,
|
||||
// DXN / BC5 — 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
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Fallback: load a raw DDS file ─────────────────────────────────────────────
|
||||
|
||||
/// Loads a standard DDS file (if you've pre-converted game textures using
|
||||
/// a tool like Noesis). This is the fast path for early Milestone 1 work
|
||||
/// before the XPR2 loader is fully validated.
|
||||
///
|
||||
/// Bevy has built-in DDS support via the `dds` feature.
|
||||
/// Just use: asset_server.load("textures/ship01.dds")
|
||||
/// No custom loader needed for DDS!
|
||||
pub struct DdsTextureInfo;
|
||||
impl DdsTextureInfo {
|
||||
/// Use this path in Bevy for standard DDS files:
|
||||
/// ```rust,no_run
|
||||
/// # use bevy::prelude::*;
|
||||
/// # fn example(asset_server: Res<AssetServer>) {
|
||||
/// let handle: Handle<Image> = asset_server.load("textures/ship.dds");
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn note() {}
|
||||
}
|
||||
117
crates/sylpheed-viewer/src/camera.rs
Normal file
117
crates/sylpheed-viewer/src/camera.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! Orbit camera for inspecting 3D assets.
|
||||
//!
|
||||
//! Controls:
|
||||
//! - Left-click + drag → orbit
|
||||
//! - Right-click + drag → pan
|
||||
//! - Scroll wheel → zoom
|
||||
//! - R → reset to default view
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::input::mouse::{MouseMotion, MouseWheel};
|
||||
|
||||
pub struct OrbitCameraPlugin;
|
||||
|
||||
impl Plugin for OrbitCameraPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, spawn_camera)
|
||||
.add_systems(Update, orbit_camera);
|
||||
}
|
||||
}
|
||||
|
||||
/// State for the orbit camera controller.
|
||||
#[derive(Component)]
|
||||
pub struct OrbitCamera {
|
||||
/// Distance from the focus point
|
||||
pub radius: f32,
|
||||
/// Rotation around the vertical axis (azimuth) in radians
|
||||
pub yaw: f32,
|
||||
/// Rotation around the horizontal axis (elevation) in radians
|
||||
pub pitch: f32,
|
||||
/// The point the camera orbits around
|
||||
pub focus: Vec3,
|
||||
pub orbit_sensitivity: f32,
|
||||
pub zoom_sensitivity: f32,
|
||||
pub pan_sensitivity: f32,
|
||||
}
|
||||
|
||||
impl Default for OrbitCamera {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
radius: 5.0,
|
||||
yaw: std::f32::consts::FRAC_PI_4,
|
||||
pitch: std::f32::consts::FRAC_PI_6,
|
||||
focus: Vec3::ZERO,
|
||||
orbit_sensitivity: 0.005,
|
||||
zoom_sensitivity: 0.3,
|
||||
pan_sensitivity: 0.003,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_camera(mut commands: Commands) {
|
||||
let orbit = OrbitCamera::default();
|
||||
let transform = orbit_transform(&orbit);
|
||||
|
||||
commands.spawn((
|
||||
Camera3d::default(),
|
||||
transform,
|
||||
orbit,
|
||||
));
|
||||
}
|
||||
|
||||
fn orbit_camera(
|
||||
mut query: Query<(&mut OrbitCamera, &mut Transform)>,
|
||||
mouse_buttons: Res<ButtonInput<MouseButton>>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut mouse_motion: EventReader<MouseMotion>,
|
||||
mut scroll: EventReader<MouseWheel>,
|
||||
) {
|
||||
let Ok((mut cam, mut transform)) = query.get_single_mut() else { return };
|
||||
|
||||
let mut delta_motion = Vec2::ZERO;
|
||||
for ev in mouse_motion.read() {
|
||||
delta_motion += ev.delta;
|
||||
}
|
||||
|
||||
let mut scroll_delta = 0.0f32;
|
||||
for ev in scroll.read() {
|
||||
scroll_delta += ev.y;
|
||||
}
|
||||
|
||||
// Orbit (left mouse drag)
|
||||
if mouse_buttons.pressed(MouseButton::Left) {
|
||||
cam.yaw -= delta_motion.x * cam.orbit_sensitivity;
|
||||
cam.pitch -= delta_motion.y * cam.orbit_sensitivity;
|
||||
// Clamp pitch to avoid gimbal lock
|
||||
cam.pitch = cam.pitch.clamp(-1.5, 1.5);
|
||||
}
|
||||
|
||||
// Pan (right mouse drag)
|
||||
if mouse_buttons.pressed(MouseButton::Right) {
|
||||
let right = transform.rotation * Vec3::X;
|
||||
let up = transform.rotation * Vec3::Y;
|
||||
// Copy fields before mutably borrowing `cam.focus`
|
||||
let pan_sens = cam.pan_sensitivity;
|
||||
let radius = cam.radius;
|
||||
cam.focus -= right * delta_motion.x * pan_sens * radius;
|
||||
cam.focus += up * delta_motion.y * pan_sens * radius;
|
||||
}
|
||||
|
||||
// Zoom (scroll)
|
||||
cam.radius -= scroll_delta * cam.zoom_sensitivity * cam.radius;
|
||||
cam.radius = cam.radius.clamp(0.5, 50.0);
|
||||
|
||||
// Reset (R key)
|
||||
if keys.just_pressed(KeyCode::KeyR) {
|
||||
*cam = OrbitCamera::default();
|
||||
}
|
||||
|
||||
*transform = orbit_transform(&cam);
|
||||
}
|
||||
|
||||
fn orbit_transform(cam: &OrbitCamera) -> Transform {
|
||||
let rotation = Quat::from_euler(EulerRot::YXZ, cam.yaw, cam.pitch, 0.0);
|
||||
let offset = rotation * Vec3::new(0.0, 0.0, cam.radius);
|
||||
Transform::from_translation(cam.focus + offset)
|
||||
.looking_at(cam.focus, Vec3::Y)
|
||||
}
|
||||
95
crates/sylpheed-viewer/src/lib.rs
Normal file
95
crates/sylpheed-viewer/src/lib.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
//! # sylpheed-viewer
|
||||
//!
|
||||
//! Bevy-based asset viewer for Project Sylpheed: Arc of Deception.
|
||||
//!
|
||||
//! This crate serves as both:
|
||||
//! - A **native binary** (via `main.rs`) — full desktop viewer
|
||||
//! - A **WASM library** (this file) — browser viewer at `index.html`
|
||||
//!
|
||||
//! ## Architecture
|
||||
//! - `sylpheed_formats` handles all binary parsing — no Bevy dependency
|
||||
//! - This crate is the thin Bevy integration layer on top
|
||||
//! - `asset_loader` bridges the two: wraps parsers as `AssetLoader` impls
|
||||
//!
|
||||
//! ## WASM notes
|
||||
//! On the web, local file access is unavailable. Assets must be
|
||||
//! pre-extracted and served over HTTP. XISO reading is native-only.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::EguiPlugin;
|
||||
|
||||
pub mod asset_loader;
|
||||
pub mod camera;
|
||||
pub mod ui;
|
||||
|
||||
// ── Application state ────────────────────────────────────────────────────────
|
||||
|
||||
/// Global viewer state, shared across all UI and rendering systems.
|
||||
#[derive(Resource)]
|
||||
pub struct ViewerState {
|
||||
/// Which type of asset is currently being browsed / previewed.
|
||||
pub current_mode: ViewMode,
|
||||
/// Whether to render meshes in wireframe mode (toggle with F2).
|
||||
pub wireframe: bool,
|
||||
}
|
||||
|
||||
impl Default for ViewerState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
current_mode: ViewMode::Textures,
|
||||
wireframe: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The active view / asset type being inspected.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ViewMode {
|
||||
/// XPR2 texture preview (Milestone 1).
|
||||
#[default]
|
||||
Textures,
|
||||
/// 3D mesh viewer (Milestone 2+).
|
||||
Mesh,
|
||||
/// Audio waveform / playback (Milestone 2+).
|
||||
Audio,
|
||||
}
|
||||
|
||||
// ── App builder ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build and run the viewer application.
|
||||
///
|
||||
/// Called from `main.rs` on native and from `wasm_bindgen` init on the web.
|
||||
pub fn run() {
|
||||
let mut app = App::new();
|
||||
|
||||
app.add_plugins(
|
||||
DefaultPlugins.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
title: "Project Sylpheed: Arc of Deception — Asset Viewer".into(),
|
||||
resolution: (1280.0, 720.0).into(),
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
}),
|
||||
);
|
||||
|
||||
app.add_plugins(EguiPlugin);
|
||||
app.add_plugins(asset_loader::SylpheedAssetPlugin);
|
||||
app.add_plugins(camera::OrbitCameraPlugin);
|
||||
app.add_plugins(ui::ViewerUiPlugin);
|
||||
|
||||
app.init_resource::<ViewerState>();
|
||||
|
||||
// Minimal 3D scene: a directional light so meshes are visible
|
||||
app.add_systems(Startup, setup_scene);
|
||||
|
||||
app.run();
|
||||
}
|
||||
|
||||
fn setup_scene(mut commands: Commands) {
|
||||
commands.spawn(DirectionalLight {
|
||||
illuminance: 10_000.0,
|
||||
shadows_enabled: true,
|
||||
..default()
|
||||
});
|
||||
}
|
||||
21
crates/sylpheed-viewer/src/main.rs
Normal file
21
crates/sylpheed-viewer/src/main.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! Native entry point for the sylpheed-viewer binary.
|
||||
//!
|
||||
//! On desktop, this is the executable that gets built by
|
||||
//! `cargo run --bin sylpheed-viewer` or `just run`.
|
||||
//!
|
||||
//! On the web, `lib.rs` is the WASM entry point instead.
|
||||
|
||||
fn main() {
|
||||
// Initialise logging for native (WASM uses browser console automatically)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive("sylpheed=info".parse().unwrap())
|
||||
.add_directive("wgpu=warn".parse().unwrap())
|
||||
.add_directive("bevy=warn".parse().unwrap()),
|
||||
)
|
||||
.init();
|
||||
|
||||
sylpheed_viewer::run();
|
||||
}
|
||||
187
crates/sylpheed-viewer/src/ui.rs
Normal file
187
crates/sylpheed-viewer/src/ui.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
//! egui-based debug UI for the asset viewer.
|
||||
//!
|
||||
//! Provides panels for:
|
||||
//! - File browser (list extracted game files)
|
||||
//! - Texture inspector (preview + format info)
|
||||
//! - Mesh inspector (vertex/index counts, materials)
|
||||
//! - RE notes (track discoveries during reverse engineering)
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts};
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/// State for the file browser panel.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct FileBrowserState {
|
||||
pub files: Vec<String>,
|
||||
pub selected: Option<usize>,
|
||||
pub filter: String,
|
||||
}
|
||||
|
||||
fn draw_viewer_ui(
|
||||
mut contexts: EguiContexts,
|
||||
mut viewer: ResMut<ViewerState>,
|
||||
mut browser: ResMut<FileBrowserState>,
|
||||
asset_server: Res<AssetServer>,
|
||||
) {
|
||||
let ctx = contexts.ctx_mut();
|
||||
|
||||
// ── Menu bar ──────────────────────────────────────────────────────────
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
ui.menu_button("View", |ui| {
|
||||
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() {
|
||||
viewer.current_mode = ViewMode::Mesh;
|
||||
}
|
||||
if ui.selectable_label(viewer.current_mode == ViewMode::Audio, "Audio").clicked() {
|
||||
viewer.current_mode = ViewMode::Audio;
|
||||
}
|
||||
ui.separator();
|
||||
ui.checkbox(&mut viewer.wireframe, "Wireframe (F2)");
|
||||
});
|
||||
|
||||
ui.menu_button("Help", |ui| {
|
||||
if ui.button("Controls...").clicked() {
|
||||
// TODO: controls popup
|
||||
}
|
||||
if ui.button("About").clicked() {
|
||||
// TODO: about dialog
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Left panel: file browser ──────────────────────────────────────────
|
||||
egui::SidePanel::left("file_browser")
|
||||
.resizable(true)
|
||||
.default_width(280.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.heading("Game Files");
|
||||
ui.separator();
|
||||
|
||||
// Filter input
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Filter:");
|
||||
ui.text_edit_singleline(&mut browser.filter);
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
// File list
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
if browser.files.is_empty() {
|
||||
ui.colored_label(
|
||||
egui::Color32::YELLOW,
|
||||
"⚠ No files loaded.\nExtract your ISO first:\n\
|
||||
xdvdfs unpack game.iso ./assets/",
|
||||
);
|
||||
} else {
|
||||
let filter = browser.filter.to_lowercase();
|
||||
// Collect owned strings so we can later mutate `browser.selected`
|
||||
let filtered: Vec<(usize, String)> = browser.files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.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());
|
||||
if ui.add(label).clicked() {
|
||||
browser.selected = Some(*idx);
|
||||
info!("Selected file: {}", file);
|
||||
// TODO: load the selected file into the viewer
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bottom panel: status bar ──────────────────────────────────────────
|
||||
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
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"),
|
||||
};
|
||||
ui.separator();
|
||||
ui.label(format!("{} files loaded", browser.files.len()));
|
||||
ui.separator();
|
||||
ui.label("LMB drag: orbit | RMB drag: pan | Scroll: zoom | R: reset");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Central area: RE notes (shown when no asset is selected) ──────────
|
||||
if browser.selected.is_none() {
|
||||
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.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.collapsing("Reverse Engineering Notes", |ui| {
|
||||
ui.label("Document your findings here as you RE the formats.");
|
||||
ui.separator();
|
||||
egui::Grid::new("re_notes").striped(true).show(ui, |ui| {
|
||||
ui.strong("Extension");
|
||||
ui.strong("Status");
|
||||
ui.strong("Notes");
|
||||
ui.end_row();
|
||||
|
||||
ui.label(".xpr");
|
||||
ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Partial");
|
||||
ui.label("XPR2 container + DXT1/3/5 textures");
|
||||
ui.end_row();
|
||||
|
||||
ui.label("?");
|
||||
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
|
||||
ui.label("Mesh format — need to identify extension");
|
||||
ui.end_row();
|
||||
|
||||
ui.label("?");
|
||||
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
|
||||
ui.label("Audio format — likely XWB wave banks");
|
||||
ui.end_row();
|
||||
|
||||
ui.label("?");
|
||||
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
|
||||
ui.label("Mission/level data — format unknown");
|
||||
ui.end_row();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user