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