diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-11 02:37:39 -0600 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-11 02:37:39 -0600 |
| commit | 7ee008e150bc5bcf76082d726f719ee0fdfda982 (patch) | |
| tree | 27fb39be241fdb5ac2734c574de678977d1856d0 /libs/bsa_ffi/src/archive/reader.rs | |
Fluorine Manager: full Linux port of Mod Organizer 2
Complete native Linux port with FUSE-based virtual filesystem,
Proton/umu-run integration, and Flatpak packaging.
Key features:
- FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak)
- Proton/GE-Proton/umu-run launcher with env var forwarding
- Flatpak support (sandbox-aware VFS, NXM handler, umu-run)
- Wine prefix management UI
- Case-insensitive path resolution for Linux filesystems
- QSettings-safe INI handling (avoids Bethesda INI corruption)
- Portable instance support with auto-generated launcher scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'libs/bsa_ffi/src/archive/reader.rs')
| -rw-r--r-- | libs/bsa_ffi/src/archive/reader.rs | 154 |
1 files changed, 154 insertions, 0 deletions
diff --git a/libs/bsa_ffi/src/archive/reader.rs b/libs/bsa_ffi/src/archive/reader.rs new file mode 100644 index 0000000..21b291c --- /dev/null +++ b/libs/bsa_ffi/src/archive/reader.rs @@ -0,0 +1,154 @@ +//! BSA reading with parallel extraction + +use anyhow::{bail, Context, Result}; +use ba2::tes4::{Archive, File as BsaFile, FileCompressionOptions}; +use ba2::{ByteSlice, Reader}; +use rayon::prelude::*; +use std::collections::HashSet; +use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tracing::debug; + +/// Entry for a file in a BSA archive +#[derive(Debug, Clone)] +pub struct BsaFileEntry { + pub path: String, +} + +/// List all files in a BSA archive +pub fn list_files(bsa_path: &Path) -> Result<Vec<BsaFileEntry>> { + let (archive, _): (Archive, _) = Archive::read(bsa_path) + .with_context(|| format!("Failed to open BSA: {}", bsa_path.display()))?; + + let mut files = Vec::new(); + + for (dir_key, folder) in archive.iter() { + let dir_name = String::from_utf8_lossy(dir_key.name().as_bytes()); + + for (file_key, _file) in folder.iter() { + let file_name = String::from_utf8_lossy(file_key.name().as_bytes()); + + // Build full path with backslash (BSA convention) + let full_path = if dir_name.is_empty() || dir_name == "." { + file_name.to_string() + } else { + format!("{}\\{}", dir_name, file_name) + }; + + files.push(BsaFileEntry { path: full_path }); + } + } + + debug!("Listed {} files in BSA {}", files.len(), bsa_path.display()); + Ok(files) +} + +/// Extract a single file from a BSA archive +#[allow(dead_code)] +pub fn extract_file(bsa_path: &Path, file_path: &str) -> Result<Vec<u8>> { + let (archive, options): (Archive, _) = Archive::read(bsa_path) + .with_context(|| format!("Failed to open BSA: {}", bsa_path.display()))?; + + // Convert archive options to compression options (includes version info) + let compression_options: FileCompressionOptions = (&options).into(); + + // Normalize to backslashes and split + let normalized = file_path.replace('/', "\\"); + let (dir_name, file_name) = if let Some(idx) = normalized.rfind('\\') { + (&normalized[..idx], &normalized[idx + 1..]) + } else { + ("", normalized.as_str()) + }; + + // Search case-insensitively + for (dir_key, folder) in archive.iter() { + let current_dir = String::from_utf8_lossy(dir_key.name().as_bytes()); + + if current_dir.eq_ignore_ascii_case(dir_name) { + for (file_key, file) in folder.iter() { + let current_file = String::from_utf8_lossy(file_key.name().as_bytes()); + + if current_file.eq_ignore_ascii_case(file_name) { + // Extract with decompression if needed (uses version from archive options) + let data = if file.is_decompressed() { + file.as_bytes().to_vec() + } else { + file.decompress(&compression_options)?.as_bytes().to_vec() + }; + return Ok(data); + } + } + } + } + + bail!( + "File not found in BSA: {} (dir='{}', file='{}')", + file_path, + dir_name, + file_name + ) +} + +/// Extract multiple files from a BSA archive in a single parallel pass. +/// Opens the archive once, collects matching entries, then decompresses +/// and writes them in parallel using rayon. +/// `wanted` should contain lowercase backslash-separated paths. +pub fn extract_files_batch<F>( + bsa_path: &Path, + wanted: &HashSet<String>, + callback: F, +) -> Result<usize> +where + F: Fn(&str, Vec<u8>) -> Result<()> + Send + Sync, +{ + let (archive, options): (Archive, _) = Archive::read(bsa_path) + .with_context(|| format!("Failed to open BSA: {}", bsa_path.display()))?; + + let compression_options: FileCompressionOptions = (&options).into(); + + // Collect matching entries with references to file data + let mut entries: Vec<(String, &BsaFile)> = Vec::new(); + for (dir_key, folder) in archive.iter() { + let dir_name = String::from_utf8_lossy(dir_key.name().as_bytes()); + + for (file_key, file) in folder.iter() { + let file_name = String::from_utf8_lossy(file_key.name().as_bytes()); + + let full_path = if dir_name.is_empty() || dir_name == "." { + file_name.to_string() + } else { + format!("{}\\{}", dir_name, file_name) + }; + + let lookup = full_path.to_lowercase(); + if wanted.contains(&lookup) { + entries.push((full_path, file)); + } + } + } + + // Decompress + write in parallel + let extracted = AtomicUsize::new(0); + entries + .par_iter() + .try_for_each(|(path, file)| -> Result<()> { + let data = if file.is_decompressed() { + file.as_bytes().to_vec() + } else { + file.decompress(&compression_options)?.as_bytes().to_vec() + }; + + callback(path, data)?; + extracted.fetch_add(1, Ordering::Relaxed); + Ok(()) + })?; + + let count = extracted.load(Ordering::Relaxed); + debug!( + "Batch extracted {} of {} wanted files from BSA {}", + count, + wanted.len(), + bsa_path.display() + ); + Ok(count) +} |
