//! 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) -> 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, 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, 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, ) -> 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 { // Very short files can still be text (e.g. a one-line config). return if is_probably_text(bytes) { FileFormat::Text } else { 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, // No binary magic matched — fall back to a content heuristic so text // configs (`config.ini`, etc.) are recognised even though they have no // signature. _ if is_probably_text(bytes) => FileFormat::Text, _ => FileFormat::Unknown, } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FileFormat { Xpr2Texture, Riff, XwbAudio, Png, Dds, /// Plain text (ASCII / UTF-8 / UTF-16) — `.ini` and similar. Text, 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::Text => "txt", Self::Unknown => "bin", } } } // ── Text detection & decoding (pure std, WASM-safe) ─────────────────────────── /// True if `b` is a byte we'd expect inside a text file: common whitespace, /// printable ASCII, or any high byte (UTF-8 continuation / Latin-1). Mirrors the /// printable convention used by the IDXD string-pool extractor. #[inline] fn is_text_byte(b: u8) -> bool { matches!(b, b'\t' | b'\n' | b'\r') || (0x20..0x7f).contains(&b) || b >= 0x80 } /// Heuristic sniff for whether `bytes` is human-readable text. /// /// Recognises UTF-8 / UTF-16 BOMs outright, BOM-less UTF-16LE by its /// characteristic odd-position NUL bytes, and otherwise requires a byte sample /// to be almost entirely text bytes with no embedded NUL (NUL is the strongest /// binary signal). Decoding is left to [`decode_text`]. pub fn is_probably_text(bytes: &[u8]) -> bool { if bytes.is_empty() { return false; } // Byte-order marks are unambiguous. if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) // UTF-8 || bytes.starts_with(&[0xFF, 0xFE]) // UTF-16LE || bytes.starts_with(&[0xFE, 0xFF]) // UTF-16BE { return true; } // Cap the scan so huge files stay cheap. let sample = &bytes[..bytes.len().min(4096)]; // BOM-less UTF-16LE: ASCII text encodes as ` 0x00 0x00 …`, so // roughly half the bytes (the odd positions) are NUL and the even bytes are // printable. if sample.len() >= 16 { let nul_odd = sample.iter().skip(1).step_by(2).filter(|&&b| b == 0).count(); if nul_odd * 2 >= sample.len() / 2 { let printable_even = sample.iter().step_by(2).filter(|&&b| is_text_byte(b)).count(); if printable_even * 2 >= sample.len() / 2 - 2 { return true; } } } // Single-byte (ASCII / UTF-8): an embedded NUL means binary; otherwise // require the overwhelming majority to be text bytes. if sample.contains(&0) { return false; } let text = sample.iter().filter(|&&b| is_text_byte(b)).count(); text * 100 >= sample.len() * 95 } /// Decode `bytes` to a `String`, returning `(text, encoding_label)`. /// /// Honours UTF-8 / UTF-16LE / UTF-16BE BOMs and BOM-less UTF-16LE, falling back /// to lossy UTF-8. Never fails — undecodable sequences become U+FFFD. Pure std, /// so both the viewer and CLI can share it. pub fn decode_text(bytes: &[u8]) -> (String, &'static str) { if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) { return (String::from_utf8_lossy(rest).into_owned(), "UTF-8 (BOM)"); } if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) { return (decode_utf16(rest, false), "UTF-16LE (BOM)"); } if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) { return (decode_utf16(rest, true), "UTF-16BE (BOM)"); } // BOM-less UTF-16LE detection (same signal as `is_probably_text`). let sample = &bytes[..bytes.len().min(4096)]; if sample.len() >= 16 { let nul_odd = sample.iter().skip(1).step_by(2).filter(|&&b| b == 0).count(); if nul_odd * 2 >= sample.len() / 2 { return (decode_utf16(bytes, false), "UTF-16LE"); } } (String::from_utf8_lossy(bytes).into_owned(), "UTF-8 / ASCII") } fn decode_utf16(bytes: &[u8], big_endian: bool) -> String { let units: Vec = bytes .chunks_exact(2) .map(|c| { if big_endian { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_le_bytes([c[0], c[1]]) } }) .collect(); String::from_utf16_lossy(&units) } #[cfg(test)] mod tests { use super::*; #[test] fn ascii_config_is_text() { let ini = b"[Video]\r\nWidth=1280\r\nHeight=720\r\n"; assert!(is_probably_text(ini)); assert_eq!(identify_format(ini), FileFormat::Text); let (text, enc) = decode_text(ini); assert!(text.contains("Width=1280")); assert_eq!(enc, "UTF-8 / ASCII"); } #[test] fn magic_takes_precedence_over_text() { // An XPR2 header happens to start with printable bytes; the magic match // must win so it's never misfiled as text. let mut xpr = b"XPR2".to_vec(); xpr.extend_from_slice(&[0u8; 32]); assert_eq!(identify_format(&xpr), FileFormat::Xpr2Texture); } #[test] fn binary_with_nul_is_not_text() { let bin = [0u8, 1, 2, 0xFF, 0x00, 0x89, 0x10, 0x00, 0x42]; assert!(!is_probably_text(&bin)); assert_eq!(identify_format(&bin), FileFormat::Unknown); } #[test] fn utf16le_bom_is_text_and_decodes() { let data = [0xFF, 0xFE, b'H', 0x00, b'i', 0x00]; assert!(is_probably_text(&data)); assert_eq!(identify_format(&data), FileFormat::Text); let (text, enc) = decode_text(&data); assert_eq!(text, "Hi"); assert_eq!(enc, "UTF-16LE (BOM)"); } #[test] fn bomless_utf16le_detected() { // "Hello, world!!!!" as UTF-16LE, no BOM (>=16 bytes so the heuristic runs). let mut data = Vec::new(); for &b in b"Hello, world!!!!" { data.push(b); data.push(0); } assert!(is_probably_text(&data)); let (text, enc) = decode_text(&data); assert_eq!(text, "Hello, world!!!!"); assert_eq!(enc, "UTF-16LE"); } #[test] fn empty_is_not_text() { assert!(!is_probably_text(&[])); } }