aboutsummaryrefslogtreecommitdiff
path: root/libs/bsa_ffi/src/lib.rs
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-11 02:37:39 -0600
commit7ee008e150bc5bcf76082d726f719ee0fdfda982 (patch)
tree27fb39be241fdb5ac2734c574de678977d1856d0 /libs/bsa_ffi/src/lib.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/lib.rs')
-rw-r--r--libs/bsa_ffi/src/lib.rs325
1 files changed, 325 insertions, 0 deletions
diff --git a/libs/bsa_ffi/src/lib.rs b/libs/bsa_ffi/src/lib.rs
new file mode 100644
index 0000000..2cb2f21
--- /dev/null
+++ b/libs/bsa_ffi/src/lib.rs
@@ -0,0 +1,325 @@
+mod archive;
+
+use std::ffi::{c_char, c_int, CStr, CString};
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::ptr;
+
+use archive::{
+ extract_archive_files_batch, list_archive_files, Ba2Builder, Ba2Format, BsaBuilder,
+ GameVersion,
+};
+use walkdir::WalkDir;
+
+#[repr(C)]
+pub struct BsaFfiStringList {
+ pub items: *mut *mut c_char,
+ pub count: usize,
+ pub error: *mut c_char,
+}
+
+pub type BsaProgressCallback =
+ Option<unsafe extern "C" fn(done: u32, total: u32, current_path: *const c_char)>;
+
+fn to_cstring(s: &str) -> *mut c_char {
+ CString::new(s).unwrap_or_default().into_raw()
+}
+
+fn error_list(msg: &str) -> BsaFfiStringList {
+ BsaFfiStringList {
+ items: ptr::null_mut(),
+ count: 0,
+ error: to_cstring(msg),
+ }
+}
+
+unsafe fn from_cstr<'a>(p: *const c_char) -> Result<&'a str, &'static str> {
+ if p.is_null() {
+ return Err("null pointer");
+ }
+
+ CStr::from_ptr(p)
+ .to_str()
+ .map_err(|_| "invalid UTF-8 string")
+}
+
+fn call_progress(progress_cb: BsaProgressCallback, done: usize, total: usize, path: &str) {
+ if let Some(cb) = progress_cb {
+ if let Ok(c_path) = CString::new(path) {
+ unsafe {
+ cb(done as u32, total as u32, c_path.as_ptr());
+ }
+ }
+ }
+}
+
+fn path_to_rel(root: &Path, child: &Path) -> anyhow::Result<String> {
+ let rel = child.strip_prefix(root)?;
+ Ok(rel.to_string_lossy().replace('\\', "/"))
+}
+
+fn include_file_for_mode(rel: &str, include_mode: i32) -> bool {
+ let is_dds = rel.to_lowercase().ends_with(".dds");
+ match include_mode {
+ 1 => !is_dds,
+ 2 => is_dds,
+ _ => true,
+ }
+}
+
+#[no_mangle]
+pub unsafe extern "C" fn bsa_ffi_list_files(archive_path: *const c_char) -> BsaFfiStringList {
+ let archive_path = match from_cstr(archive_path) {
+ Ok(v) => v,
+ Err(e) => return error_list(e),
+ };
+
+ let entries = match list_archive_files(Path::new(archive_path)) {
+ Ok(v) => v,
+ Err(e) => return error_list(&e.to_string()),
+ };
+
+ let mut items: Vec<*mut c_char> = entries.into_iter().map(|e| to_cstring(&e.path)).collect();
+ let result = BsaFfiStringList {
+ items: items.as_mut_ptr(),
+ count: items.len(),
+ error: ptr::null_mut(),
+ };
+ std::mem::forget(items);
+ result
+}
+
+#[no_mangle]
+pub unsafe extern "C" fn bsa_ffi_string_list_free(list: BsaFfiStringList) {
+ if !list.items.is_null() {
+ let items = Vec::from_raw_parts(list.items, list.count, list.count);
+ for p in items {
+ if !p.is_null() {
+ let _ = CString::from_raw(p);
+ }
+ }
+ }
+
+ if !list.error.is_null() {
+ let _ = CString::from_raw(list.error);
+ }
+}
+
+#[no_mangle]
+pub unsafe extern "C" fn bsa_ffi_string_free(s: *mut c_char) {
+ if !s.is_null() {
+ let _ = CString::from_raw(s);
+ }
+}
+
+#[no_mangle]
+pub unsafe extern "C" fn bsa_ffi_extract_all(
+ archive_path: *const c_char,
+ output_dir: *const c_char,
+ progress_cb: BsaProgressCallback,
+ cancel_flag: *const c_int,
+) -> *mut c_char {
+ let archive_path = match from_cstr(archive_path) {
+ Ok(v) => v,
+ Err(e) => return to_cstring(e),
+ };
+ let output_dir = match from_cstr(output_dir) {
+ Ok(v) => v,
+ Err(e) => return to_cstring(e),
+ };
+
+ let archive_path = PathBuf::from(archive_path);
+ let output_dir = PathBuf::from(output_dir);
+
+ if let Err(e) = fs::create_dir_all(&output_dir) {
+ return to_cstring(&format!("failed to create output directory: {e}"));
+ }
+
+ let entries = match list_archive_files(&archive_path) {
+ Ok(v) => v,
+ Err(e) => return to_cstring(&e.to_string()),
+ };
+
+ let total = entries.len();
+ let wanted_files: Vec<String> = entries.iter().map(|e| e.path.clone()).collect();
+ let progress_count = std::sync::atomic::AtomicUsize::new(0);
+ let cancel_addr = cancel_flag as usize;
+
+ let res = extract_archive_files_batch(&archive_path, &wanted_files, |path, data| {
+ let cancel_ptr = cancel_addr as *const c_int;
+ if !cancel_ptr.is_null() {
+ let cancelled = unsafe { *cancel_ptr } != 0;
+ if cancelled {
+ anyhow::bail!("cancelled");
+ }
+ }
+
+ let out_path = output_dir.join(path.replace('\\', "/"));
+ if let Some(parent) = out_path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+ fs::write(&out_path, &data)?;
+
+ let done = progress_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
+ call_progress(progress_cb, done, total, path);
+ Ok(())
+ });
+
+ match res {
+ Ok(_) => ptr::null_mut(),
+ Err(e) => to_cstring(&e.to_string()),
+ }
+}
+
+#[no_mangle]
+pub unsafe extern "C" fn bsa_ffi_pack_dir(
+ input_dir: *const c_char,
+ output_archive: *const c_char,
+ game_id: *const c_char,
+ progress_cb: BsaProgressCallback,
+ cancel_flag: *const c_int,
+) -> *mut c_char {
+ bsa_ffi_pack_dir_filtered(
+ input_dir,
+ output_archive,
+ game_id,
+ 0,
+ progress_cb,
+ cancel_flag,
+ )
+}
+
+#[no_mangle]
+pub unsafe extern "C" fn bsa_ffi_pack_dir_filtered(
+ input_dir: *const c_char,
+ output_archive: *const c_char,
+ game_id: *const c_char,
+ include_mode: c_int,
+ progress_cb: BsaProgressCallback,
+ cancel_flag: *const c_int,
+) -> *mut c_char {
+ let input_dir = match from_cstr(input_dir) {
+ Ok(v) => v,
+ Err(e) => return to_cstring(e),
+ };
+ let output_archive = match from_cstr(output_archive) {
+ Ok(v) => v,
+ Err(e) => return to_cstring(e),
+ };
+ let game_id = match from_cstr(game_id) {
+ Ok(v) => v,
+ Err(e) => return to_cstring(e),
+ };
+
+ let game = match GameVersion::from_cli_name(game_id) {
+ Some(v) => v,
+ None => {
+ let valid = GameVersion::all()
+ .iter()
+ .map(GameVersion::cli_name)
+ .collect::<Vec<_>>()
+ .join(", ");
+ return to_cstring(&format!("unknown game_id '{game_id}', valid: {valid}"));
+ }
+ };
+
+ let input_dir = PathBuf::from(input_dir);
+ let output_archive = PathBuf::from(output_archive);
+
+ let mut files: Vec<(String, Vec<u8>)> = Vec::new();
+ for entry in WalkDir::new(&input_dir).into_iter().filter_map(|e| e.ok()) {
+ if !entry.file_type().is_file() {
+ continue;
+ }
+
+ if !cancel_flag.is_null() {
+ let cancelled = unsafe { *cancel_flag } != 0;
+ if cancelled {
+ return to_cstring("cancelled");
+ }
+ }
+
+ let rel = match path_to_rel(&input_dir, entry.path()) {
+ Ok(v) => v,
+ Err(e) => return to_cstring(&format!("path error: {e}")),
+ };
+
+ if !include_file_for_mode(&rel, include_mode) {
+ continue;
+ }
+
+ let data = match fs::read(entry.path()) {
+ Ok(v) => v,
+ Err(e) => return to_cstring(&format!("read error: {e}")),
+ };
+
+ files.push((rel, data));
+ }
+
+ if files.is_empty() {
+ return to_cstring("no files found in input_dir");
+ }
+
+ let total = files.len();
+
+ if game.is_ba2() {
+ let ba2_version = game.ba2_version().unwrap_or_default();
+ let compression = game.ba2_compression();
+
+ let format = if output_archive
+ .file_name()
+ .map(|n| n.to_string_lossy().to_lowercase().contains("textures"))
+ .unwrap_or(false)
+ {
+ Ba2Format::DX10
+ } else {
+ Ba2Format::General
+ };
+
+ let mut builder = Ba2Builder::new()
+ .with_version(ba2_version)
+ .with_compression(compression)
+ .with_format(format);
+
+ for (idx, (rel, data)) in files.into_iter().enumerate() {
+ if !cancel_flag.is_null() {
+ let cancelled = unsafe { *cancel_flag } != 0;
+ if cancelled {
+ return to_cstring("cancelled");
+ }
+ }
+ builder.add_file(&rel, data);
+ call_progress(progress_cb, idx + 1, total, &rel);
+ }
+
+ match builder.build_with_progress(&output_archive, |_, _, _| {}) {
+ Ok(_) => ptr::null_mut(),
+ Err(e) => to_cstring(&e.to_string()),
+ }
+ } else {
+ let version = match game.bsa_version() {
+ Some(v) => v,
+ None => return to_cstring("TES3 BSA writing is not supported yet"),
+ };
+
+ let compress = game.supports_compression();
+
+ let mut builder = BsaBuilder::new().with_version(version).with_compression(compress);
+
+ for (idx, (rel, data)) in files.into_iter().enumerate() {
+ if !cancel_flag.is_null() {
+ let cancelled = unsafe { *cancel_flag } != 0;
+ if cancelled {
+ return to_cstring("cancelled");
+ }
+ }
+ builder.add_file(&rel, data);
+ call_progress(progress_cb, idx + 1, total, &rel);
+ }
+
+ match builder.build_with_progress(&output_archive, |_, _, _| {}) {
+ Ok(_) => ptr::null_mut(),
+ Err(e) => to_cstring(&e.to_string()),
+ }
+ }
+}