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:
MechaCat02
2026-03-25 21:04:07 +01:00
commit f8127e73b0
23 changed files with 8107 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
[package]
name = "sylpheed-viewer"
description = "Bevy-based asset viewer for Project Sylpheed: Arc of Deception"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
# Native binary entry point
[[bin]]
name = "sylpheed-viewer"
path = "src/main.rs"
# Library crate — also serves as the WASM entry point
[lib]
name = "sylpheed_viewer"
path = "src/lib.rs"
# cdylib is required for wasm-bindgen to produce a .wasm file
crate-type = ["cdylib", "rlib"]
[dependencies]
sylpheed-formats = { workspace = true }
bevy = { workspace = true, features = [
"bevy_asset",
"bevy_color",
"bevy_core_pipeline",
"bevy_pbr",
"bevy_render",
"bevy_window",
"bevy_winit",
"multi_threaded",
"png",
# Linux display backends
"x11",
"wayland",
] }
bevy_egui = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[features]
# Enable Bevy dynamic linking for faster incremental recompiles in dev
dev = ["bevy/dynamic_linking"]
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tracing-subscriber = { workspace = true }

View File

@@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Project Sylpheed: Arc of Deception — Asset Viewer</title>
<style>
:root {
--bg: #0a0c12;
--surface: #12151f;
--accent: #4fc3f7;
--accent2: #7c4dff;
--text: #e0e6f0;
--text-dim: #6b7a99;
--border: #1e2438;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Courier New', monospace;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* ── Header bar ── */
header {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.logo {
font-size: 11px;
font-weight: bold;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--accent);
}
.logo span {
color: var(--accent2);
}
.build-badge {
font-size: 10px;
padding: 2px 8px;
border: 1px solid var(--accent2);
border-radius: 3px;
color: var(--accent2);
letter-spacing: 1px;
}
/* ── Main layout ── */
main {
flex: 1;
position: relative;
overflow: hidden;
}
/* ── Bevy canvas ── */
#bevy-canvas {
width: 100%;
height: 100%;
display: block;
background: var(--bg);
/* Prevent context menu on right-click (used for panning) */
touch-action: none;
}
/* ── Loading overlay ── */
#loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg);
z-index: 10;
gap: 24px;
transition: opacity 0.5s ease;
}
#loading.hidden {
opacity: 0;
pointer-events: none;
}
.loading-title {
font-size: 22px;
letter-spacing: 6px;
text-transform: uppercase;
color: var(--accent);
}
.loading-title em {
color: var(--text-dim);
font-style: normal;
}
.progress-bar-container {
width: 360px;
height: 2px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent2), var(--accent));
border-radius: 2px;
width: 0%;
animation: progress-anim 2.5s ease-in-out forwards;
}
@keyframes progress-anim {
0% { width: 0%; }
40% { width: 55%; }
70% { width: 75%; }
90% { width: 88%; }
/* The remaining % is filled by JS when WASM is ready */
}
.loading-status {
font-size: 11px;
color: var(--text-dim);
letter-spacing: 2px;
}
/* ── Warning banner for WASM limitations ── */
#wasm-note {
padding: 6px 20px;
background: #1a1500;
border-top: 1px solid #3d3000;
font-size: 11px;
color: #b8a040;
letter-spacing: 0.5px;
flex-shrink: 0;
text-align: center;
}
</style>
</head>
<body>
<header>
<div class="logo">PROJECT <span>SYLPHEED</span> // ASSET VIEWER</div>
<div class="build-badge">WEB BUILD</div>
<div class="build-badge" style="border-color: var(--accent); color: var(--accent);">
MILESTONE 1
</div>
</header>
<main>
<!-- Loading overlay -->
<div id="loading">
<div class="loading-title">
PROJECT <em>SYLPHEED</em>
</div>
<div>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="progress-fill"></div>
</div>
</div>
<div class="loading-status" id="loading-status">
INITIALIZING WASM RUNTIME...
</div>
</div>
<!-- Bevy renders into this canvas -->
<!-- The id must match the canvas option in lib.rs WindowPlugin -->
<canvas id="bevy-canvas"></canvas>
</main>
<div id="wasm-note">
⚠ Web build: asset loading is limited. For full ISO extraction support, use the native build.
Pre-extract assets with: <code>xdvdfs unpack game.iso ./assets/</code>
</div>
<!-- Trunk injects the compiled WASM module here -->
<link data-trunk rel="rust" data-wasm-opt="z" />
<script>
// Update loading status messages as WASM initializes
const statusEl = document.getElementById('loading-status');
const loadingEl = document.getElementById('loading');
const fillEl = document.getElementById('progress-fill');
const messages = [
[400, 'LOADING WASM MODULE...'],
[900, 'INITIALIZING BEVY ECS...'],
[1500, 'SETTING UP RENDER PIPELINE...'],
[2000, 'REGISTERING ASSET LOADERS...'],
[2400, 'READY'],
];
messages.forEach(([delay, msg]) => {
setTimeout(() => { statusEl.textContent = msg; }, delay);
});
// Once the canvas gets its first frame, hide the loading overlay
const observer = new MutationObserver(() => {
const canvas = document.getElementById('bevy-canvas');
if (canvas && canvas.width > 0) {
fillEl.style.width = '100%';
setTimeout(() => { loadingEl.classList.add('hidden'); }, 300);
observer.disconnect();
}
});
observer.observe(document.getElementById('bevy-canvas'), {
attributes: true, attributeFilter: ['width', 'height']
});
// Prevent right-click context menu on the canvas (RMB = pan)
document.getElementById('bevy-canvas').addEventListener(
'contextmenu', e => e.preventDefault()
);
</script>
</body>
</html>

View 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() {}
}

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

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

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

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