`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
323 lines
10 KiB
Rust
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(&[]));
|
|
}
|
|
}
|