Files
Sylpheed/crates/sylpheed-formats/src/vfs.rs
Fabian Hamm c4c914ff59
Some checks failed
CI / Native — linux (pull_request) Successful in 32m7s
CI / WASM — Web (pull_request) Failing after 8m3s
CI / Formatting (pull_request) Successful in 50s
style: rustfmt sweep -- 774 hunks across 154 files -> 0
`cargo fmt --all -- --check` has failed on every run in this repository's
history, identically on `main` and on every branch. This is #12.

Mechanical: `cargo fmt --all`, nothing else. 154 files, all `.rs`, no other
extension touched. `cargo check --workspace` exits 0 afterwards, so nothing
changed semantically.

ON THE ORDERING, WHICH WAS THE REAL QUESTION.

HANDOFF-2026-09-06 section 7 warns this is the expensive fix: a whole-tree
reformat before #7 and #8 return "would put a conflict in every file of 861
commits and make the reviews those items exist to enable unreadable".

That is measurably too pessimistic, and it had been reasoned rather than
tested. Measured here by three-way merging a rustfmt'd `main` against both
unmerged branches, file by file:

  file/branch pairs tested   32
  merges CLEAN               28
  merges CONFLICTING          4   (8 conflict hunks total)

    sylpheed-cli/src/main.rs      1 hunk
    sylpheed-export/src/check.rs  1
    sylpheed-export/src/screen.rs 4
    sylpheed-export/src/video.rs  2

All four are against `auto/frame-blend-draw-path` only;
`auto/port-p6-audio` does not conflict anywhere. The earlier framing --
154 dirty files, 133 that cannot collide, 21 that can, the collision set
carrying 147 of 774 hunks (19%) -- reproduces exactly. What it did not say
is that most of the 21 still merge cleanly, because rustfmt's edits and the
branches' edits rarely land on the same lines.

So the cost of sweeping now is 4 files and 8 hunks for one branch, against
a check that is otherwise red forever. Deliberately NOT folded into the
WASM PR: 154 reformatted files would make that one unreviewable.

Closes #12
2026-09-08 20:07:01 +02:00

323 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
.as_chunks::<2>()
.0
.iter()
.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(&[]));
}
}