diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index 012a733..5dbf201 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -272,6 +272,7 @@ fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> { "bin" => label.red().to_string(), "xpr" => label.green().to_string(), "dds" => label.green().to_string(), + "txt" => label.cyan().to_string(), _ => label.yellow().to_string(), }; diff --git a/crates/sylpheed-formats/src/vfs.rs b/crates/sylpheed-formats/src/vfs.rs index 0cd43f2..28749f2 100644 --- a/crates/sylpheed-formats/src/vfs.rs +++ b/crates/sylpheed-formats/src/vfs.rs @@ -108,14 +108,23 @@ impl GameAssets { /// Use this to identify unknown file types during reverse engineering. pub fn identify_format(bytes: &[u8]) -> FileFormat { if bytes.len() < 4 { - return FileFormat::Unknown; + // 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 + 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, } } @@ -127,6 +136,8 @@ pub enum FileFormat { XwbAudio, Png, Dds, + /// Plain text (ASCII / UTF-8 / UTF-16) — `.ini` and similar. + Text, Unknown, } @@ -138,7 +149,162 @@ impl FileFormat { 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(&[])); + } +} diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index 81f6c6f..3ae8c51 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -90,6 +90,22 @@ pub struct FileInfo { pub detected_format: Option, } +/// Decoded contents of the currently-selected text file, shown in the central +/// panel. `content` is `None` whenever the selection isn't text. +#[derive(Resource, Default)] +pub struct TextPreview { + /// Decoded UTF-8 (any BOM / UTF-16 already resolved), or `None`. + pub content: Option, + /// Encoding label for the header, e.g. "UTF-16LE (BOM)". + pub encoding: String, + /// True when the file was larger than `MAX_TEXT_BYTES` and got clipped. + pub truncated: bool, +} + +/// Cap on the decoded text we hand to egui — its text layout cost grows +/// super-linearly, and disc config files are only a few KiB anyway. +const MAX_TEXT_BYTES: usize = 2 * 1024 * 1024; + // ── SystemSet label ─────────────────────────────────────────────────────────── /// `draw_viewer_ui` runs `.after(IsoLoaderSystemSet)` to see the frame's @@ -157,7 +173,10 @@ impl Plugin for IsoLoaderPlugin { .add_event::() .init_resource::() .init_resource::() - .init_resource::(); + .init_resource::() + // Registered unconditionally (even on wasm, where the load systems + // below are cfg'd out) so the UI can always read it. + .init_resource::(); #[cfg(not(target_arch = "wasm32"))] { @@ -392,6 +411,7 @@ fn poll_loader_channel( fn apply_loaded_texture( mut pending: ResMut, mut preview: ResMut, + mut text_preview: ResMut, mut file_info: ResMut, mut images: ResMut>, mut contexts: bevy_egui::EguiContexts, @@ -412,6 +432,9 @@ fn apply_loaded_texture( images.remove(handle.id()); } *preview = TexturePreview::default(); + // Clear stale text too, so switching files never shows a previous file's + // content in the wrong panel. + *text_preview = TextPreview::default(); // Populate FileInfo for the status / info panel. file_info.name = path @@ -422,6 +445,25 @@ fn apply_loaded_texture( file_info.size_bytes = bytes.len(); file_info.detected_format = Some(fmt); + if fmt == FileFormat::Text { + let (mut text, encoding) = sylpheed_formats::vfs::decode_text(&bytes); + let truncated = text.len() > MAX_TEXT_BYTES; + if truncated { + // Cut on a char boundary so we never split a codepoint. + let end = text + .char_indices() + .take_while(|(i, _)| *i < MAX_TEXT_BYTES) + .last() + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(0); + text.truncate(end); + } + text_preview.content = Some(text); + text_preview.encoding = encoding.to_string(); + text_preview.truncated = truncated; + return; // FileInfo + TextPreview are sufficient for text files. + } + if fmt != FileFormat::Xpr2Texture { return; // Not a texture — FileInfo is sufficient } diff --git a/crates/sylpheed-viewer/src/lib.rs b/crates/sylpheed-viewer/src/lib.rs index b6e4008..191de1d 100644 --- a/crates/sylpheed-viewer/src/lib.rs +++ b/crates/sylpheed-viewer/src/lib.rs @@ -32,6 +32,8 @@ pub struct ViewerState { pub current_mode: ViewMode, /// Whether to render meshes in wireframe mode (toggle with F2). pub wireframe: bool, + /// Whether the plain-text viewer wraps long lines. + pub text_wrap: bool, } impl Default for ViewerState { @@ -39,6 +41,7 @@ impl Default for ViewerState { Self { current_mode: ViewMode::Textures, wireframe: false, + text_wrap: true, } } } diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index a4fdccb..83689d8 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -10,7 +10,7 @@ use bevy::prelude::*; use bevy_egui::{egui, EguiContexts}; use crate::iso_loader::{ - FileInfo, FileSelected, IsoState, RequestOpenDir, RequestOpenIso, TexturePreview, + FileInfo, FileSelected, IsoState, RequestOpenDir, RequestOpenIso, TextPreview, TexturePreview, IsoLoaderSystemSet, }; use crate::{ViewerState, ViewMode}; @@ -33,12 +33,91 @@ pub struct FileBrowserState { pub filter: String, } +// ── Directory tree ──────────────────────────────────────────────────────────── + +/// One directory node in the ephemeral file tree. Rebuilt from the flat path +/// list each frame; leaves carry their original index into `browser.files` so +/// selection stays stable regardless of tree shape or filtering. +#[derive(Default)] +struct TreeDir { + /// Child directories, keyed by name (`BTreeMap` = stable alphabetical order). + dirs: std::collections::BTreeMap, + /// Leaf files: (display name, original index into `browser.files`). + files: Vec<(String, usize)>, +} + +/// Split a full path into (directory components, leaf name), grouping data +/// segments (`foo.p00`, `foo.p01`, …) under a virtual `foo.pak` directory so a +/// pack and its segments sit together. Purely cosmetic — indices are untouched. +fn split_for_tree(full: &str) -> (Vec, String) { + let mut comps: Vec = full.split('/').map(str::to_string).collect(); + let leaf = comps.pop().unwrap_or_default(); + if let Some((stem, ext)) = leaf.rsplit_once('.') { + let e = ext.as_bytes(); + // `.pNN` where NN are two digits → nest under ".pak". + if e.len() == 3 && e[0] == b'p' && e[1].is_ascii_digit() && e[2].is_ascii_digit() { + comps.push(format!("{stem}.pak")); + } + } + (comps, leaf) +} + +/// Build the directory tree, keeping only leaves whose full path contains +/// `filter` (case-insensitive; empty filter keeps everything). +fn build_tree(files: &[String], filter: &str) -> TreeDir { + let filter = filter.to_lowercase(); + let mut root = TreeDir::default(); + for (idx, full) in files.iter().enumerate() { + if !filter.is_empty() && !full.to_lowercase().contains(&filter) { + continue; + } + let (dirs, leaf) = split_for_tree(full); + let mut node = &mut root; + for comp in dirs { + node = node.dirs.entry(comp).or_default(); + } + node.files.push((leaf, idx)); + } + root +} + +/// Render a directory node recursively: sub-directories (collapsible) first, +/// then leaf files as selectable labels. +fn render_dir( + ui: &mut egui::Ui, + node: &TreeDir, + force_open: bool, + selected: &mut Option, + events: &mut EventWriter, + files: &[String], +) { + for (name, child) in &node.dirs { + egui::CollapsingHeader::new(format!("📁 {name}")) + .id_salt(name) + .default_open(force_open) + .show(ui, |ui| { + render_dir(ui, child, force_open, selected, events, files); + }); + } + for (leaf, idx) in &node.files { + let is_selected = *selected == Some(*idx); + if ui + .add(egui::SelectableLabel::new(is_selected, leaf.as_str())) + .clicked() + { + *selected = Some(*idx); + events.send(FileSelected(files[*idx].clone())); + } + } +} + fn draw_viewer_ui( mut contexts: EguiContexts, mut viewer: ResMut, mut browser: ResMut, iso_state: Res, preview: Res, + text_preview: Res, file_info: Res, mut open_iso_events: EventWriter, mut open_dir_events: EventWriter, @@ -138,26 +217,21 @@ fn draw_viewer_ui( or File → Open extracted folder.", ); } else { - let filter = browser.filter.to_lowercase(); - let filtered: Vec<(usize, String)> = browser - .files - .iter() - .enumerate() - .filter(|(_, f)| { - filter.is_empty() || f.to_lowercase().contains(&filter) - }) - .map(|(i, f)| (i, f.clone())) - .collect(); - - for (idx, file) in &filtered { - let is_selected = browser.selected == Some(*idx); - let label = - egui::SelectableLabel::new(is_selected, file.as_str()); - if ui.add(label).clicked() { - browser.selected = Some(*idx); - file_selected_events.send(FileSelected(file.clone())); - } - } + // Build a directory tree from the flat path list. Cheap to + // rebuild every frame (a few hundred short paths); cloning + // `files` first sidesteps borrowing `browser` immutably and + // mutably (selected) at the same time. + let files = browser.files.clone(); + let tree = build_tree(&files, &browser.filter); + let force_open = !browser.filter.is_empty(); + render_dir( + ui, + &tree, + force_open, + &mut browser.selected, + &mut file_selected_events, + &files, + ); } }); }); @@ -184,7 +258,37 @@ fn draw_viewer_ui( // ── Central area ────────────────────────────────────────────────────── if browser.selected.is_some() { egui::CentralPanel::default().show(ctx, |ui| { - if let Some(egui_id) = preview.egui_id { + if let Some(text) = &text_preview.content { + // ── Plain-text viewer ── + ui.horizontal(|ui| { + ui.heading(&file_info.name); + ui.separator(); + ui.label(&text_preview.encoding); + ui.separator(); + ui.checkbox(&mut viewer.text_wrap, "Wrap"); + if text_preview.truncated { + ui.separator(); + ui.colored_label(egui::Color32::YELLOW, "(truncated)"); + } + }); + ui.separator(); + + egui::ScrollArea::both().show(ui, |ui| { + // Read-only: `&str` is a non-editable TextBuffer, and + // `.interactive(false)` keeps it selectable/copyable. + let mut buf = text.as_str(); + let mut edit = egui::TextEdit::multiline(&mut buf) + .font(egui::TextStyle::Monospace) + .code_editor() + .desired_width(f32::INFINITY) + .interactive(false); + if !viewer.text_wrap { + // Don't wrap: let ScrollArea scroll horizontally. + edit = edit.clip_text(false); + } + ui.add(edit); + }); + } else if let Some(egui_id) = preview.egui_id { // Texture preview ui.heading(&file_info.name); ui.label(&preview.format_info);