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:
144
crates/sylpheed-formats/src/vfs.rs
Normal file
144
crates/sylpheed-formats/src/vfs.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user