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,144 @@
//! Virtual file system abstraction.
//!
//! `GameAssets` provides a unified interface for accessing game files
//! regardless of whether they're in an extracted directory (the normal
//! development workflow) or read directly from an XISO (less common).
//!
//! ## Recommended workflow
//! 1. Run `xdvdfs unpack your_game.iso ./extracted/` once
//! 2. Point `GameAssets` at `./extracted/`
//! 3. Iterate fast — no ISO re-parsing on every launch
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum VfsError {
#[error("File not found: {0}")]
NotFound(String),
#[error("IO error reading {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
}
/// Provides access to extracted game files.
///
/// All paths are relative to the extraction root and use `/` as separator.
/// The implementation handles OS-specific path separators internally.
pub struct GameAssets {
root: PathBuf,
}
impl GameAssets {
/// Create a new `GameAssets` pointing at a directory of extracted files.
///
/// Call `xdvdfs unpack game.iso ./extracted/` first.
pub fn from_directory(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
/// Read a file's bytes by its in-game path.
///
/// # Example
/// ```no_run
/// # use sylpheed_formats::vfs::GameAssets;
/// let assets = GameAssets::from_directory("./extracted");
/// let bytes = assets.read("DEFAULT.XEX").unwrap();
/// ```
pub fn read(&self, game_path: &str) -> Result<Vec<u8>, VfsError> {
let disk_path = self.resolve(game_path);
std::fs::read(&disk_path).map_err(|e| VfsError::Io {
path: game_path.to_string(),
source: e,
})
}
/// Check whether a file exists.
pub fn exists(&self, game_path: &str) -> bool {
self.resolve(game_path).exists()
}
/// List all files under a subdirectory, recursively.
///
/// Returns paths relative to the extraction root, with `/` separators.
pub fn list(&self, subdir: &str) -> Result<Vec<String>, VfsError> {
let dir = self.resolve(subdir);
let mut result = Vec::new();
self.walk_dir(&dir, &dir, &mut result).map_err(|e| VfsError::Io {
path: subdir.to_string(),
source: e,
})?;
Ok(result)
}
/// Resolve a game-relative path to an OS filesystem path.
pub fn resolve(&self, game_path: &str) -> PathBuf {
// Normalize separators and join to root
let native = game_path.replace('/', std::path::MAIN_SEPARATOR_STR);
self.root.join(native)
}
fn walk_dir(
&self,
dir: &Path,
root: &Path,
out: &mut Vec<String>,
) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
self.walk_dir(&path, root, out)?;
} else {
// Make path relative and use forward slashes
let rel = path.strip_prefix(root).unwrap_or(&path);
out.push(rel.to_string_lossy().replace('\\', "/"));
}
}
Ok(())
}
}
// ── Helpers for common file type detection ────────────────────────────────────
/// Sniff a file's format from its first bytes (magic number).
/// Use this to identify unknown file types during reverse engineering.
pub fn identify_format(bytes: &[u8]) -> FileFormat {
if bytes.len() < 4 {
return FileFormat::Unknown;
}
match &bytes[..4] {
b"XPR2" => FileFormat::Xpr2Texture,
b"RIFF" => FileFormat::Riff, // could be WAV or XWB
b"XWB\0" => FileFormat::XwbAudio, // Xbox Wave Bank
[0x89, b'P', b'N', b'G'] => FileFormat::Png,
b"DDS " => FileFormat::Dds,
_ => FileFormat::Unknown,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileFormat {
Xpr2Texture,
Riff,
XwbAudio,
Png,
Dds,
Unknown,
}
impl FileFormat {
pub fn extension_hint(self) -> &'static str {
match self {
Self::Xpr2Texture => "xpr",
Self::Riff => "riff",
Self::XwbAudio => "xwb",
Self::Png => "png",
Self::Dds => "dds",
Self::Unknown => "bin",
}
}
}