Files
Syplheed-Reborn/crates/sylpheed-formats/src/vfs.rs
MechaCat02 2658dd20a6 feat(viewer): directory-tree browser + plain-text viewer
Explorer drawer was a flat list of all 353 disc files and only textures had a
preview. Two additions:

1. Directory tree — the left drawer is now a collapsible `📁` tree, rebuilt each
   frame from the path list (ui.rs: build_tree / split_for_tree / render_dir).
   Filtering narrows leaves and auto-expands matches; `.p00…p04` data segments
   nest under a virtual `<stem>.pak` node. Selection still keys off the original
   file index, so the loader pipeline is untouched.

2. Plain-text viewer — config.ini and any file that sniffs as text now show
   read-only, selectable, monospace content with a Wrap toggle and an encoding
   label. Detection/decoding live in the Bevy-free formats crate:
   vfs::is_probably_text + vfs::decode_text (BOM-aware UTF-8/UTF-16LE/BE, BOM-less
   UTF-16LE heuristic, NUL-reject + 95%-printable fallback) — pure std, WASM-safe,
   unit-tested (6 new tests), and reused by the CLI sniffer (new cyan `txt`).

New TextPreview resource (registered unconditionally so it exists on wasm);
populated in apply_loaded_texture with a 2 MiB char-boundary-safe cap. Also fixes
a latent bug: switching from a texture to a non-texture left stale preview state —
both texture and text state now reset on every selection.

ViewerState gains text_wrap. Formats tests: 22 pass; workspace compiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:05:58 +02:00

311 lines
10 KiB
Rust

//! 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 {
// 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 `<char> 0x00 <char> 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<u16> = 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(&[]));
}
}