aboutsummaryrefslogtreecommitdiff
path: root/libs/bsa_ffi
diff options
context:
space:
mode:
Diffstat (limited to 'libs/bsa_ffi')
-rw-r--r--libs/bsa_ffi/CMakeLists.txt34
-rw-r--r--libs/bsa_ffi/Cargo.lock520
-rw-r--r--libs/bsa_ffi/Cargo.toml18
-rw-r--r--libs/bsa_ffi/include/bsa_ffi.h54
-rw-r--r--libs/bsa_ffi/src/archive/ba2_reader.rs125
-rw-r--r--libs/bsa_ffi/src/archive/ba2_writer.rs336
-rw-r--r--libs/bsa_ffi/src/archive/mod.rs436
-rw-r--r--libs/bsa_ffi/src/archive/reader.rs154
-rw-r--r--libs/bsa_ffi/src/archive/tes3_reader.rs104
-rw-r--r--libs/bsa_ffi/src/archive/writer.rs256
-rw-r--r--libs/bsa_ffi/src/lib.rs325
11 files changed, 2362 insertions, 0 deletions
diff --git a/libs/bsa_ffi/CMakeLists.txt b/libs/bsa_ffi/CMakeLists.txt
new file mode 100644
index 0000000..26da7ff
--- /dev/null
+++ b/libs/bsa_ffi/CMakeLists.txt
@@ -0,0 +1,34 @@
+cmake_minimum_required(VERSION 3.16)
+project(bsa_ffi)
+
+if(CMAKE_BUILD_TYPE STREQUAL "Debug")
+ set(CARGO_BUILD_TYPE "debug")
+ set(CARGO_BUILD_FLAGS "")
+else()
+ set(CARGO_BUILD_TYPE "release")
+ set(CARGO_BUILD_FLAGS "--release")
+endif()
+
+set(BSA_FFI_DIR ${CMAKE_CURRENT_SOURCE_DIR})
+set(BSA_FFI_LIB ${BSA_FFI_DIR}/target/${CARGO_BUILD_TYPE}/libbsa_ffi.so)
+
+add_custom_command(
+ OUTPUT ${BSA_FFI_LIB}
+ COMMAND cargo build ${CARGO_BUILD_FLAGS}
+ WORKING_DIRECTORY ${BSA_FFI_DIR}
+ COMMENT "Building BSA FFI library (Rust)"
+ DEPENDS
+ ${BSA_FFI_DIR}/Cargo.toml
+ ${BSA_FFI_DIR}/src/lib.rs
+)
+
+add_custom_target(bsa_ffi_build DEPENDS ${BSA_FFI_LIB})
+
+add_library(bsa_ffi_wrapper INTERFACE)
+target_link_libraries(bsa_ffi_wrapper INTERFACE ${BSA_FFI_LIB})
+target_include_directories(bsa_ffi_wrapper INTERFACE ${BSA_FFI_DIR}/include)
+add_dependencies(bsa_ffi_wrapper bsa_ffi_build)
+add_library(mo2::bsa_ffi ALIAS bsa_ffi_wrapper)
+
+install(FILES ${BSA_FFI_LIB} DESTINATION lib)
+install(DIRECTORY ${BSA_FFI_DIR}/include/ DESTINATION include)
diff --git a/libs/bsa_ffi/Cargo.lock b/libs/bsa_ffi/Cargo.lock
new file mode 100644
index 0000000..62d98ad
--- /dev/null
+++ b/libs/bsa_ffi/Cargo.lock
@@ -0,0 +1,520 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "anyhow"
+version = "1.0.101"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
+
+[[package]]
+name = "ba2"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fdb05c5c954898b463887df1145016492deee06e9a778f8af491c7cde14c210"
+dependencies = [
+ "bitflags",
+ "bstr",
+ "directxtex",
+ "flate2",
+ "lzzzz",
+ "memmap2",
+ "thiserror",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
+
+[[package]]
+name = "bstr"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+dependencies = [
+ "memchr",
+ "regex-automata",
+ "serde",
+]
+
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "cc"
+version = "1.2.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "directxtex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f0cea4cfe7ef3eeeae77cbbe6c8eddb7e518b1af877ca788b7a9d4fde45e658"
+dependencies = [
+ "bitflags",
+ "cc",
+ "winresult",
+]
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "libz-sys",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom",
+ "libc",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.181"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
+
+[[package]]
+name = "libz-sys"
+version = "1.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "lz4_flex"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
+dependencies = [
+ "twox-hash",
+]
+
+[[package]]
+name = "lzzzz"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac94cca0c9c2ac03c63092f1377df5b83e4c35441f9d83a53ca214c58685f7bd"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "memmap2"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mo2_bsa_ffi"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "ba2",
+ "byteorder",
+ "flate2",
+ "lz4_flex",
+ "rayon",
+ "tracing",
+ "walkdir",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rayon"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+
+[[package]]
+name = "syn"
+version = "2.0.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "twox-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasip2"
+version = "1.0.2+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "winresult"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fe1d4015321f3df114e73ccc695d62c1566b33b0a2d03d553b7dd65dc11f58d"
+dependencies = [
+ "winresult-types",
+]
+
+[[package]]
+name = "winresult-types"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7314fd4ed99b7cc41081400f5d072919967e7382d358bc9e7d72bdd1955e1e8"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
diff --git a/libs/bsa_ffi/Cargo.toml b/libs/bsa_ffi/Cargo.toml
new file mode 100644
index 0000000..4b5ef20
--- /dev/null
+++ b/libs/bsa_ffi/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "mo2_bsa_ffi"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+name = "bsa_ffi"
+crate-type = ["cdylib"]
+
+[dependencies]
+anyhow = "1.0"
+ba2 = "3.0.1"
+rayon = "1.11"
+flate2 = "1.1"
+lz4_flex = "0.11"
+byteorder = "1.5"
+tracing = "0.1"
+walkdir = "2.5"
diff --git a/libs/bsa_ffi/include/bsa_ffi.h b/libs/bsa_ffi/include/bsa_ffi.h
new file mode 100644
index 0000000..32dcee3
--- /dev/null
+++ b/libs/bsa_ffi/include/bsa_ffi.h
@@ -0,0 +1,54 @@
+#ifndef BSA_FFI_H
+#define BSA_FFI_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct {
+ char **items;
+ size_t count;
+ char *error;
+} BsaFfiStringList;
+
+typedef void (*BsaProgressCallback)(uint32_t done, uint32_t total,
+ const char *current_path);
+
+/* Returns list of paths in archive. On error, error is non-null and must be freed with
+ * bsa_ffi_string_list_free(). */
+BsaFfiStringList bsa_ffi_list_files(const char *archive_path);
+
+void bsa_ffi_string_list_free(BsaFfiStringList list);
+
+/* Returns NULL on success, else an allocated error string (free with bsa_ffi_string_free). */
+char *bsa_ffi_extract_all(const char *archive_path, const char *output_dir,
+ BsaProgressCallback progress_cb, const int *cancel_flag);
+
+/* game_id uses CLI ids from GameVersion::cli_name():
+ * morrowind, oblivion, fo3, fonv, skyrimle, skyrimse,
+ * fo4-fo76, fo4ng-v7, fo4ng-v8, starfield-v2, starfield-v3
+ */
+char *bsa_ffi_pack_dir(const char *input_dir, const char *output_archive,
+ const char *game_id, BsaProgressCallback progress_cb,
+ const int *cancel_flag);
+
+/* include_mode:
+ * 0 = all files
+ * 1 = exclude .dds
+ * 2 = only .dds
+ */
+char *bsa_ffi_pack_dir_filtered(const char *input_dir, const char *output_archive,
+ const char *game_id, int include_mode,
+ BsaProgressCallback progress_cb,
+ const int *cancel_flag);
+
+void bsa_ffi_string_free(char *s);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/libs/bsa_ffi/src/archive/ba2_reader.rs b/libs/bsa_ffi/src/archive/ba2_reader.rs
new file mode 100644
index 0000000..e6dd984
--- /dev/null
+++ b/libs/bsa_ffi/src/archive/ba2_reader.rs
@@ -0,0 +1,125 @@
+//! BA2 (Fallout 4/Starfield) archive reading
+//!
+//! Provides read support for FO4 format BA2 files (Fallout 4, Fallout 76, Starfield).
+
+use anyhow::{bail, Context, Result};
+use ba2::fo4::{Archive, File as Ba2File, FileWriteOptions};
+use ba2::prelude::*;
+use ba2::ByteSlice;
+use rayon::prelude::*;
+use std::collections::HashSet;
+use std::io::Cursor;
+use std::path::Path;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use tracing::debug;
+
+/// Entry for a file in a BA2 archive
+#[derive(Debug, Clone)]
+pub struct Ba2FileEntry {
+ pub path: String,
+}
+
+/// List all files in a BA2 archive
+pub fn list_files(ba2_path: &Path) -> Result<Vec<Ba2FileEntry>> {
+ let (archive, _options): (Archive, _) = Archive::read(ba2_path)
+ .with_context(|| format!("Failed to open BA2: {}", ba2_path.display()))?;
+
+ let mut files = Vec::new();
+
+ for (key, _file) in archive.iter() {
+ let path = String::from_utf8_lossy(key.name().as_bytes()).to_string();
+
+ files.push(Ba2FileEntry { path });
+ }
+
+ debug!("Listed {} files in BA2 {}", files.len(), ba2_path.display());
+ Ok(files)
+}
+
+/// Extract a single file from a BA2 archive
+#[allow(dead_code)]
+pub fn extract_file(ba2_path: &Path, file_path: &str) -> Result<Vec<u8>> {
+ let (archive, options): (Archive, _) = Archive::read(ba2_path)
+ .with_context(|| format!("Failed to open BA2: {}", ba2_path.display()))?;
+
+ let write_options: FileWriteOptions = options.into();
+
+ // Normalize path for comparison (BA2 uses forward slashes typically)
+ let normalized = file_path.replace('\\', "/").to_lowercase();
+ let normalized_backslash = file_path.replace('/', "\\").to_lowercase();
+
+ for (key, file) in archive.iter() {
+ let current_path = String::from_utf8_lossy(key.name().as_bytes()).to_lowercase();
+
+ // Try both slash conventions
+ if current_path == normalized
+ || current_path == normalized_backslash
+ || current_path.replace('\\', "/") == normalized
+ || current_path.replace('/', "\\") == normalized_backslash
+ {
+ // Write to memory buffer
+ let mut buffer = Cursor::new(Vec::new());
+ file.write(&mut buffer, &write_options)
+ .with_context(|| format!("Failed to extract file: {}", file_path))?;
+
+ return Ok(buffer.into_inner());
+ }
+ }
+
+ bail!(
+ "File not found in BA2: {} (searched for '{}')",
+ file_path,
+ normalized
+ )
+}
+
+/// Extract multiple files from a BA2 archive in parallel.
+/// Opens the archive once, collects matching entries, then decompresses
+/// and writes them in parallel using rayon.
+/// `wanted` should contain lowercase forward-slash-separated paths.
+pub fn extract_files_batch<F>(
+ ba2_path: &Path,
+ wanted: &HashSet<String>,
+ callback: F,
+) -> Result<usize>
+where
+ F: Fn(&str, Vec<u8>) -> Result<()> + Send + Sync,
+{
+ let (archive, options): (Archive, _) = Archive::read(ba2_path)
+ .with_context(|| format!("Failed to open BA2: {}", ba2_path.display()))?;
+
+ let write_options: FileWriteOptions = options.into();
+
+ // Collect matching entries with references
+ let mut entries: Vec<(String, &Ba2File)> = Vec::new();
+ for (key, file) in archive.iter() {
+ let path = String::from_utf8_lossy(key.name().as_bytes()).to_string();
+ let lookup = path.replace('\\', "/").to_lowercase();
+ if wanted.contains(&lookup) {
+ entries.push((path, file));
+ }
+ }
+
+ // Decompress + write in parallel
+ let extracted = AtomicUsize::new(0);
+ entries
+ .par_iter()
+ .try_for_each(|(path, file)| -> Result<()> {
+ let mut buffer = Cursor::new(Vec::new());
+ file.write(&mut buffer, &write_options)
+ .with_context(|| format!("Failed to extract file: {}", path))?;
+
+ callback(path, buffer.into_inner())?;
+ extracted.fetch_add(1, Ordering::Relaxed);
+ Ok(())
+ })?;
+
+ let count = extracted.load(Ordering::Relaxed);
+ debug!(
+ "Batch extracted {} of {} wanted files from BA2 {}",
+ count,
+ wanted.len(),
+ ba2_path.display()
+ );
+ Ok(count)
+}
diff --git a/libs/bsa_ffi/src/archive/ba2_writer.rs b/libs/bsa_ffi/src/archive/ba2_writer.rs
new file mode 100644
index 0000000..aa461d5
--- /dev/null
+++ b/libs/bsa_ffi/src/archive/ba2_writer.rs
@@ -0,0 +1,336 @@
+//! BA2 (Fallout 4/Starfield) archive creation
+//!
+//! Provides write support for FO4 format BA2 files (Fallout 4, Fallout 76, Starfield).
+
+use anyhow::{bail, Context, Result};
+use ba2::fo4::{
+ Archive, ArchiveKey, ArchiveOptionsBuilder, Chunk, ChunkCompressionOptions,
+ CompressionFormat as Ba2CrateCompression, CompressionLevel, File as Ba2File,
+ FileReadOptionsBuilder, Format, Version,
+};
+use ba2::prelude::*;
+use ba2::{CompressionResult, Copied};
+use rayon::prelude::*;
+use std::collections::HashMap;
+use std::fs;
+use std::io::BufWriter;
+use std::path::Path;
+use tracing::info;
+
+/// BA2 archive version
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum Ba2Version {
+ /// Fallout 4 Old Gen, Fallout 76
+ V1,
+ /// Starfield
+ V2,
+ /// Starfield
+ V3,
+ /// Fallout 4 Next Gen
+ #[default]
+ V7,
+ /// Fallout 4 Next Gen
+ V8,
+}
+
+impl Ba2Version {
+ /// Convert to the ba2 crate's Version type
+ pub fn to_crate_version(self) -> Version {
+ match self {
+ Ba2Version::V1 => Version::v1,
+ Ba2Version::V2 => Version::v2,
+ Ba2Version::V3 => Version::v3,
+ Ba2Version::V7 => Version::v7,
+ Ba2Version::V8 => Version::v8,
+ }
+ }
+}
+
+/// Compression format for BA2 archives
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum Ba2CompressionFormat {
+ /// No compression
+ None,
+ /// zlib compression (Fallout 4, Fallout 76)
+ #[default]
+ Zlib,
+ /// LZ4 compression (Starfield)
+ Lz4,
+}
+
+/// Archive format variant
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum Ba2Format {
+ /// General archive (GNRL) - for meshes, scripts, etc.
+ #[default]
+ General,
+ /// DirectX 10 textures (DX10) - for DDS textures
+ DX10,
+}
+
+/// Builder for creating BA2 archives
+pub struct Ba2Builder {
+ /// Files organized by path -> data
+ files: HashMap<String, Vec<u8>>,
+ /// Archive format (General or DX10)
+ format: Ba2Format,
+ /// Compression format
+ compression: Ba2CompressionFormat,
+ /// Whether to include string table
+ strings: bool,
+ /// Archive version
+ version: Ba2Version,
+}
+
+impl Ba2Builder {
+ pub fn new() -> Self {
+ Self {
+ files: HashMap::new(),
+ format: Ba2Format::General,
+ compression: Ba2CompressionFormat::Zlib,
+ strings: true,
+ version: Ba2Version::default(),
+ }
+ }
+
+ /// Create builder with settings detected from BA2 name
+ #[allow(dead_code)]
+ pub fn from_name(name: &str) -> Self {
+ let name_lower = name.to_lowercase();
+
+ // Texture archives need DX10 format for proper texture headers
+ let is_texture_archive = {
+ let filename = name_lower.rsplit(['/', '\\']).next().unwrap_or(&name_lower);
+ filename.contains(" - textures")
+ || filename.starts_with("textures")
+ || (filename.contains("textures")
+ && !filename.contains(" - main")
+ && !filename.contains("_main"))
+ };
+
+ let format = if is_texture_archive {
+ Ba2Format::DX10
+ } else {
+ Ba2Format::General
+ };
+
+ // Default to zlib compression for FO4
+ let compression = Ba2CompressionFormat::Zlib;
+
+ Self {
+ files: HashMap::new(),
+ format,
+ compression,
+ strings: true,
+ version: Ba2Version::default(),
+ }
+ }
+
+ /// Set archive version
+ pub fn with_version(mut self, version: Ba2Version) -> Self {
+ self.version = version;
+ self
+ }
+
+ /// Set archive format
+ pub fn with_format(mut self, format: Ba2Format) -> Self {
+ self.format = format;
+ self
+ }
+
+ /// Set compression format
+ pub fn with_compression(mut self, compression: Ba2CompressionFormat) -> Self {
+ self.compression = compression;
+ self
+ }
+
+ /// Enable or disable string table
+ #[allow(dead_code)]
+ pub fn with_strings(mut self, strings: bool) -> Self {
+ self.strings = strings;
+ self
+ }
+
+ /// Add a file to the archive
+ pub fn add_file(&mut self, path: &str, data: Vec<u8>) {
+ // Normalize: forward slashes, strip leading slash
+ let normalized = path.replace('\\', "/");
+ let normalized = normalized.trim_start_matches('/').to_string();
+ self.files.insert(normalized, data);
+ }
+
+ /// Get number of files
+ pub fn file_count(&self) -> usize {
+ self.files.len()
+ }
+
+ /// Check if empty
+ pub fn is_empty(&self) -> bool {
+ self.files.is_empty()
+ }
+
+ /// Build and write the BA2 to disk with progress callback
+ pub fn build_with_progress<F>(self, output_path: &Path, progress: F) -> Result<()>
+ where
+ F: Fn(usize, usize, &str) + Send + Sync,
+ {
+ if self.is_empty() {
+ bail!("Cannot create empty BA2 archive");
+ }
+
+ let file_count = self.file_count();
+ let total_size: u64 = self.files.values().map(|data| data.len() as u64).sum();
+
+ info!(
+ "Building BA2: {} ({} files, {} MB, format {:?}, compression {:?})",
+ output_path.display(),
+ file_count,
+ total_size / 1_000_000,
+ self.format,
+ self.compression
+ );
+
+ // For DX10 (texture) archives, we need special handling
+ if self.format == Ba2Format::DX10 {
+ return self.build_dx10_with_progress(output_path, progress);
+ }
+
+ // Build archive entries in parallel
+ let entries: Vec<(String, Vec<u8>)> = self.files.into_iter().collect();
+ let total = entries.len();
+ let processed_count = std::sync::atomic::AtomicUsize::new(0);
+ let compression = self.compression;
+
+ let archive_entries: Result<Vec<(ArchiveKey<'static>, Ba2File<'static>)>> = entries
+ .par_iter()
+ .map(|(path, data)| {
+ // Create chunk from data
+ let chunk = Chunk::from_decompressed(data.clone().into_boxed_slice());
+
+ // Optionally compress the chunk
+ let chunk = if compression != Ba2CompressionFormat::None {
+ let options = ChunkCompressionOptions::default();
+ match chunk.compress(&options) {
+ Ok(compressed) => compressed,
+ Err(_) => chunk, // Fall back to uncompressed if compression fails
+ }
+ } else {
+ chunk
+ };
+
+ // Create file from chunk
+ let file: Ba2File = [chunk].into_iter().collect();
+
+ // Create key from path
+ let key: ArchiveKey = path.as_bytes().into();
+
+ let current =
+ processed_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
+ progress(current, total, path);
+
+ Ok((key, file))
+ })
+ .collect();
+
+ let archive_entries = archive_entries?;
+
+ // Build archive from entries
+ let archive: Archive = archive_entries.into_iter().collect();
+
+ let options = ArchiveOptionsBuilder::default()
+ .version(self.version.to_crate_version())
+ .strings(self.strings)
+ .build();
+
+ // Create parent directory
+ if let Some(parent) = output_path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+
+ // Write archive
+ let file = fs::File::create(output_path)
+ .with_context(|| format!("Failed to create BA2: {}", output_path.display()))?;
+ let mut writer = BufWriter::new(file);
+
+ archive
+ .write(&mut writer, &options)
+ .with_context(|| format!("Failed to write BA2: {}", output_path.display()))?;
+
+ info!("Created BA2: {}", output_path.display());
+ Ok(())
+ }
+
+ /// Build a DX10 (texture) archive with progress callback
+ fn build_dx10_with_progress<F>(self, output_path: &Path, progress: F) -> Result<()>
+ where
+ F: Fn(usize, usize, &str) + Send + Sync,
+ {
+ let compress = self.compression != Ba2CompressionFormat::None;
+ let entries: Vec<(String, Vec<u8>)> = self.files.into_iter().collect();
+ let total = entries.len();
+ let processed_count = std::sync::atomic::AtomicUsize::new(0);
+
+ // Build read options for DX10 format
+ let read_options = FileReadOptionsBuilder::new()
+ .format(Format::DX10)
+ .compression_format(Ba2CrateCompression::Zip)
+ .compression_level(CompressionLevel::FO4)
+ .compression_result(if compress {
+ CompressionResult::Compressed
+ } else {
+ CompressionResult::Decompressed
+ })
+ .build();
+
+ let archive_entries: Result<Vec<(ArchiveKey<'static>, Ba2File<'static>)>> = entries
+ .par_iter()
+ .map(|(path, data)| {
+ let file = Ba2File::read(Copied(data), &read_options)
+ .with_context(|| format!("Failed to parse DDS texture: {}", path))?;
+
+ let key: ArchiveKey = path.as_bytes().into();
+
+ let current =
+ processed_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
+ progress(current, total, path);
+
+ Ok((key, file))
+ })
+ .collect();
+
+ let archive_entries = archive_entries?;
+ let archive: Archive = archive_entries.into_iter().collect();
+
+ let options = ArchiveOptionsBuilder::default()
+ .version(self.version.to_crate_version())
+ .format(Format::DX10)
+ .compression_format(Ba2CrateCompression::Zip)
+ .strings(self.strings)
+ .build();
+
+ if let Some(parent) = output_path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+
+ let file = fs::File::create(output_path)
+ .with_context(|| format!("Failed to create BA2: {}", output_path.display()))?;
+ let mut writer = BufWriter::new(file);
+
+ archive
+ .write(&mut writer, &options)
+ .with_context(|| format!("Failed to write BA2: {}", output_path.display()))?;
+
+ info!(
+ "Created DX10 BA2: {} ({} files)",
+ output_path.display(),
+ total
+ );
+ Ok(())
+ }
+}
+
+impl Default for Ba2Builder {
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/libs/bsa_ffi/src/archive/mod.rs b/libs/bsa_ffi/src/archive/mod.rs
new file mode 100644
index 0000000..247789e
--- /dev/null
+++ b/libs/bsa_ffi/src/archive/mod.rs
@@ -0,0 +1,436 @@
+//! BSA/BA2 (Bethesda Archive) handling
+//!
+//! Provides read/write support for:
+//! - TES3 format BSA files (Morrowind)
+//! - TES4 format BSA files (Oblivion, FO3, FNV, Skyrim)
+//! - FO4 format BA2 files (Fallout 4, Fallout 76, Starfield)
+
+mod ba2_reader;
+mod ba2_writer;
+mod reader;
+mod tes3_reader;
+mod writer;
+
+pub use reader::{
+ extract_file, extract_files_batch as extract_bsa_files_batch, list_files, BsaFileEntry,
+};
+pub use writer::BsaBuilder;
+
+// TES3 (Morrowind) support
+pub use tes3_reader::{
+ extract_file as extract_tes3_file, extract_files_batch as extract_tes3_files_batch,
+ list_files as list_tes3_files,
+};
+
+// BA2 support for Fallout 4/Starfield
+pub use ba2_reader::{
+ extract_file as extract_ba2_file, extract_files_batch as extract_ba2_files_batch,
+ list_files as list_ba2_files,
+};
+pub use ba2_writer::{Ba2Builder, Ba2CompressionFormat, Ba2Format, Ba2Version};
+
+use anyhow::{bail, Result};
+use ba2::tes4::{ArchiveFlags, ArchiveTypes, Version};
+use ba2::{guess_format, FileFormat, Reader};
+use std::collections::HashSet;
+use std::fs::File;
+use std::io::BufReader;
+use std::path::Path;
+use tracing::debug;
+
+/// Archive format type
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ArchiveFormat {
+ /// TES3 BSA (Morrowind)
+ Tes3Bsa,
+ /// TES4 BSA (Oblivion, FO3, FNV, Skyrim)
+ Bsa,
+ /// FO4 BA2 (Fallout 4, Fallout 76, Starfield)
+ Ba2,
+}
+
+/// Detect archive format using ba2 crate's guess_format
+pub fn detect_format(path: &Path) -> Option<ArchiveFormat> {
+ // Use ba2's built-in format detection
+ if let Ok(file) = File::open(path) {
+ let mut reader = BufReader::new(file);
+ if let Some(format) = guess_format(&mut reader) {
+ let result = match format {
+ FileFormat::TES3 => ArchiveFormat::Tes3Bsa,
+ FileFormat::TES4 => ArchiveFormat::Bsa,
+ FileFormat::FO4 => ArchiveFormat::Ba2,
+ };
+ debug!("Detected {:?} format for: {}", result, path.display());
+ return Some(result);
+ }
+ }
+
+ // Fall back to extension
+ let ext = path.extension()?.to_str()?.to_lowercase();
+ match ext.as_str() {
+ "bsa" => {
+ debug!(
+ "Detected BSA by extension (assuming TES4): {}",
+ path.display()
+ );
+ Some(ArchiveFormat::Bsa)
+ }
+ "ba2" => {
+ debug!("Detected BA2 by extension: {}", path.display());
+ Some(ArchiveFormat::Ba2)
+ }
+ _ => None,
+ }
+}
+
+/// Universal archive file entry
+#[derive(Debug, Clone)]
+pub struct ArchiveFileEntry {
+ pub path: String,
+}
+
+/// List files from any Bethesda archive (TES3 BSA, TES4 BSA, or BA2)
+pub fn list_archive_files(archive_path: &Path) -> Result<Vec<ArchiveFileEntry>> {
+ match detect_format(archive_path) {
+ Some(ArchiveFormat::Tes3Bsa) => {
+ let files = list_tes3_files(archive_path)?;
+ Ok(files
+ .into_iter()
+ .map(|f| ArchiveFileEntry { path: f.path })
+ .collect())
+ }
+ Some(ArchiveFormat::Bsa) => {
+ let files = list_files(archive_path)?;
+ Ok(files
+ .into_iter()
+ .map(|f| ArchiveFileEntry { path: f.path })
+ .collect())
+ }
+ Some(ArchiveFormat::Ba2) => {
+ let files = list_ba2_files(archive_path)?;
+ Ok(files
+ .into_iter()
+ .map(|f| ArchiveFileEntry { path: f.path })
+ .collect())
+ }
+ None => bail!("Unknown archive format: {}", archive_path.display()),
+ }
+}
+
+/// Extract a file from any Bethesda archive (TES3 BSA, TES4 BSA, or BA2)
+#[allow(dead_code)]
+pub fn extract_archive_file(archive_path: &Path, file_path: &str) -> Result<Vec<u8>> {
+ let format = detect_format(archive_path);
+ debug!(
+ "extract_archive_file: archive={}, file={}, format={:?}",
+ archive_path.display(),
+ file_path,
+ format
+ );
+ match format {
+ Some(ArchiveFormat::Tes3Bsa) => extract_tes3_file(archive_path, file_path),
+ Some(ArchiveFormat::Bsa) => extract_file(archive_path, file_path),
+ Some(ArchiveFormat::Ba2) => extract_ba2_file(archive_path, file_path),
+ None => bail!("Unknown archive format: {}", archive_path.display()),
+ }
+}
+
+/// Extract multiple files from any Bethesda archive in a single pass.
+/// Opens the archive once and calls the callback for each extracted file.
+/// `wanted_files` should contain the original paths (as returned by list_archive_files).
+/// Returns the number of files successfully extracted.
+pub fn extract_archive_files_batch<F>(
+ archive_path: &Path,
+ wanted_files: &[String],
+ callback: F,
+) -> Result<usize>
+where
+ F: Fn(&str, Vec<u8>) -> Result<()> + Send + Sync,
+{
+ let format = detect_format(archive_path);
+ match format {
+ Some(ArchiveFormat::Tes3Bsa) => {
+ let wanted: HashSet<String> = wanted_files.iter().map(|p| p.to_lowercase()).collect();
+ extract_tes3_files_batch(archive_path, &wanted, callback)
+ }
+ Some(ArchiveFormat::Bsa) => {
+ // BSA uses backslash-separated paths
+ let wanted: HashSet<String> = wanted_files
+ .iter()
+ .map(|p| p.replace('/', "\\").to_lowercase())
+ .collect();
+ extract_bsa_files_batch(archive_path, &wanted, callback)
+ }
+ Some(ArchiveFormat::Ba2) => {
+ // BA2 uses forward-slash paths
+ let wanted: HashSet<String> = wanted_files
+ .iter()
+ .map(|p| p.replace('\\', "/").to_lowercase())
+ .collect();
+ extract_ba2_files_batch(archive_path, &wanted, callback)
+ }
+ None => bail!("Unknown archive format: {}", archive_path.display()),
+ }
+}
+
+/// Game version for archive creation
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum GameVersion {
+ /// TES3 BSA (Morrowind) - no compression
+ Morrowind,
+ /// TES4 v103 (Oblivion) - no compression
+ Oblivion,
+ /// TES4 v104 (Fallout 3) - zlib compression
+ Fallout3,
+ /// TES4 v104 (Fallout: New Vegas) - zlib compression
+ FalloutNewVegas,
+ /// TES4 v104 (Skyrim LE) - zlib compression
+ SkyrimLE,
+ /// TES4 v105 (Skyrim SE) - zlib compression
+ SkyrimSE,
+ /// BA2 v1 (Fallout 4 / Fallout 76) - zlib compression
+ #[default]
+ Fallout4Fo76,
+ /// BA2 v7 (Fallout 4 Next Gen) - zlib compression
+ Fallout4NGv7,
+ /// BA2 v8 (Fallout 4 Next Gen) - zlib compression
+ Fallout4NGv8,
+ /// BA2 v2 (Starfield) - LZ4 compression
+ StarfieldV2,
+ /// BA2 v3 (Starfield) - LZ4 compression
+ StarfieldV3,
+}
+
+impl GameVersion {
+ /// Get display name for this game version
+ pub fn display_name(&self) -> &'static str {
+ match self {
+ GameVersion::Morrowind => "Morrowind (BSA)",
+ GameVersion::Oblivion => "Oblivion (BSA v103)",
+ GameVersion::Fallout3 => "Fallout 3 (BSA v104)",
+ GameVersion::FalloutNewVegas => "Fallout: New Vegas (BSA v104)",
+ GameVersion::SkyrimLE => "Skyrim LE (BSA v104)",
+ GameVersion::SkyrimSE => "Skyrim SE (BSA v105)",
+ GameVersion::Fallout4Fo76 => "Fallout 4 / Fallout 76 (BA2 v1)",
+ GameVersion::Fallout4NGv7 => "Fallout 4 Next Gen (BA2 v7)",
+ GameVersion::Fallout4NGv8 => "Fallout 4 Next Gen (BA2 v8)",
+ GameVersion::StarfieldV2 => "Starfield (BA2 v2)",
+ GameVersion::StarfieldV3 => "Starfield (BA2 v3)",
+ }
+ }
+
+ /// Check if this game uses BA2 format
+ pub fn is_ba2(&self) -> bool {
+ matches!(
+ self,
+ GameVersion::Fallout4Fo76
+ | GameVersion::Fallout4NGv7
+ | GameVersion::Fallout4NGv8
+ | GameVersion::StarfieldV2
+ | GameVersion::StarfieldV3
+ )
+ }
+
+ /// Check if this game uses TES3 format (Morrowind)
+ pub fn is_tes3(&self) -> bool {
+ matches!(self, GameVersion::Morrowind)
+ }
+
+ /// Check if compression is supported for this game
+ pub fn supports_compression(&self) -> bool {
+ !matches!(self, GameVersion::Morrowind | GameVersion::Oblivion)
+ }
+
+ /// Get BSA version for TES4 format games
+ pub fn bsa_version(&self) -> Option<Version> {
+ match self {
+ GameVersion::Oblivion => Some(Version::v103),
+ GameVersion::Fallout3 | GameVersion::FalloutNewVegas | GameVersion::SkyrimLE => {
+ Some(Version::v104)
+ }
+ GameVersion::SkyrimSE => Some(Version::v105),
+ _ => None,
+ }
+ }
+
+ /// Get BA2 version for FO4/Starfield format games
+ pub fn ba2_version(&self) -> Option<Ba2Version> {
+ match self {
+ GameVersion::Fallout4Fo76 => Some(Ba2Version::V1),
+ GameVersion::Fallout4NGv7 => Some(Ba2Version::V7),
+ GameVersion::Fallout4NGv8 => Some(Ba2Version::V8),
+ GameVersion::StarfieldV2 => Some(Ba2Version::V2),
+ GameVersion::StarfieldV3 => Some(Ba2Version::V3),
+ _ => None,
+ }
+ }
+
+ /// Get BA2 compression format for this game
+ pub fn ba2_compression(&self) -> Ba2CompressionFormat {
+ match self {
+ GameVersion::StarfieldV2 | GameVersion::StarfieldV3 => Ba2CompressionFormat::Lz4,
+ _ => Ba2CompressionFormat::Zlib,
+ }
+ }
+
+ /// Get all game versions
+ pub fn all() -> &'static [GameVersion] {
+ &[
+ GameVersion::Morrowind,
+ GameVersion::Oblivion,
+ GameVersion::Fallout3,
+ GameVersion::FalloutNewVegas,
+ GameVersion::SkyrimLE,
+ GameVersion::SkyrimSE,
+ GameVersion::Fallout4Fo76,
+ GameVersion::Fallout4NGv7,
+ GameVersion::Fallout4NGv8,
+ GameVersion::StarfieldV2,
+ GameVersion::StarfieldV3,
+ ]
+ }
+
+ /// Convert index to game version
+ pub fn from_index(index: i32) -> GameVersion {
+ match index {
+ 0 => GameVersion::Morrowind,
+ 1 => GameVersion::Oblivion,
+ 2 => GameVersion::Fallout3,
+ 3 => GameVersion::FalloutNewVegas,
+ 4 => GameVersion::SkyrimLE,
+ 5 => GameVersion::SkyrimSE,
+ 6 => GameVersion::Fallout4Fo76,
+ 7 => GameVersion::Fallout4NGv7,
+ 8 => GameVersion::Fallout4NGv8,
+ 9 => GameVersion::StarfieldV2,
+ 10 => GameVersion::StarfieldV3,
+ _ => GameVersion::Fallout4Fo76,
+ }
+ }
+
+ /// Convert game version to index
+ pub fn index(self) -> i32 {
+ match self {
+ GameVersion::Morrowind => 0,
+ GameVersion::Oblivion => 1,
+ GameVersion::Fallout3 => 2,
+ GameVersion::FalloutNewVegas => 3,
+ GameVersion::SkyrimLE => 4,
+ GameVersion::SkyrimSE => 5,
+ GameVersion::Fallout4Fo76 => 6,
+ GameVersion::Fallout4NGv7 => 7,
+ GameVersion::Fallout4NGv8 => 8,
+ GameVersion::StarfieldV2 => 9,
+ GameVersion::StarfieldV3 => 10,
+ }
+ }
+
+ /// Short CLI-friendly name
+ pub fn cli_name(&self) -> &'static str {
+ match self {
+ GameVersion::Morrowind => "morrowind",
+ GameVersion::Oblivion => "oblivion",
+ GameVersion::Fallout3 => "fo3",
+ GameVersion::FalloutNewVegas => "fonv",
+ GameVersion::SkyrimLE => "skyrimle",
+ GameVersion::SkyrimSE => "skyrimse",
+ GameVersion::Fallout4Fo76 => "fo4-fo76",
+ GameVersion::Fallout4NGv7 => "fo4ng-v7",
+ GameVersion::Fallout4NGv8 => "fo4ng-v8",
+ GameVersion::StarfieldV2 => "starfield-v2",
+ GameVersion::StarfieldV3 => "starfield-v3",
+ }
+ }
+
+ /// Parse from CLI name (case-insensitive)
+ pub fn from_cli_name(name: &str) -> Option<GameVersion> {
+ let lower = name.to_lowercase();
+ GameVersion::all()
+ .iter()
+ .find(|v| v.cli_name() == lower)
+ .copied()
+ }
+}
+
+/// Detect game version from archive format
+pub fn detect_game_version(archive_path: &Path) -> Option<GameVersion> {
+ match detect_format(archive_path) {
+ Some(ArchiveFormat::Tes3Bsa) => Some(GameVersion::Morrowind),
+ Some(ArchiveFormat::Ba2) => Some(GameVersion::Fallout4Fo76), // Default to FO4/FO76
+ Some(ArchiveFormat::Bsa) => {
+ // Try to detect version from BSA header
+ let result: Result<(ba2::tes4::Archive, ba2::tes4::ArchiveOptions), _> =
+ ba2::tes4::Archive::read(archive_path);
+ if let Ok((_, options)) = result {
+ match options.version() {
+ Version::v103 => Some(GameVersion::Oblivion),
+ Version::v104 => Some(GameVersion::Fallout3), // Default for v104
+ Version::v105 => Some(GameVersion::SkyrimSE),
+ }
+ } else {
+ Some(GameVersion::Fallout3) // Default
+ }
+ }
+ None => None,
+ }
+}
+
+/// Default flags for FO3/FNV BSAs
+pub fn default_flags_fo3() -> ArchiveFlags {
+ ArchiveFlags::DIRECTORY_STRINGS
+ | ArchiveFlags::FILE_STRINGS
+ | ArchiveFlags::COMPRESSED
+ | ArchiveFlags::RETAIN_DIRECTORY_NAMES
+ | ArchiveFlags::RETAIN_FILE_NAMES
+ | ArchiveFlags::RETAIN_FILE_NAME_OFFSETS
+}
+
+/// Default flags for Oblivion BSAs (no compression)
+#[allow(dead_code)]
+pub fn default_flags_oblivion() -> ArchiveFlags {
+ ArchiveFlags::DIRECTORY_STRINGS | ArchiveFlags::FILE_STRINGS
+}
+
+/// Detect archive types from BSA name
+#[allow(dead_code)]
+pub fn detect_types(name: &str) -> ArchiveTypes {
+ let name_lower = name.to_lowercase();
+
+ if name_lower.contains("meshes") {
+ ArchiveTypes::MESHES
+ } else if name_lower.contains("textures") {
+ ArchiveTypes::TEXTURES
+ } else if name_lower.contains("menuvoices") {
+ ArchiveTypes::MENUS | ArchiveTypes::VOICES
+ } else if name_lower.contains("voices") {
+ ArchiveTypes::VOICES
+ } else if name_lower.contains("sound") {
+ ArchiveTypes::SOUNDS
+ } else {
+ ArchiveTypes::MISC
+ }
+}
+
+/// Detect BSA version from archive name
+#[allow(dead_code)]
+pub fn detect_version(name: &str) -> Version {
+ let name_lower = name.to_lowercase();
+
+ // Oblivion uses v103
+ if name_lower.contains("oblivion")
+ || name_lower.contains("shiveringisles")
+ || name_lower.contains("dlcshiveringisles")
+ || name_lower.contains("dlcbattlehorn")
+ || name_lower.contains("dlcfrostcrag")
+ || name_lower.contains("dlchorse")
+ || name_lower.contains("dlcorrery")
+ || name_lower.contains("dlcthievesden")
+ || name_lower.contains("dlcvilelair")
+ || name_lower.contains("knights")
+ {
+ Version::v103
+ } else {
+ // Default to FO3/FNV
+ Version::v104
+ }
+}
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)
+}
diff --git a/libs/bsa_ffi/src/archive/tes3_reader.rs b/libs/bsa_ffi/src/archive/tes3_reader.rs
new file mode 100644
index 0000000..c03f155
--- /dev/null
+++ b/libs/bsa_ffi/src/archive/tes3_reader.rs
@@ -0,0 +1,104 @@
+//! TES3 (Morrowind) BSA reading
+
+use anyhow::{bail, Context, Result};
+use ba2::tes3::{Archive, File as Tes3File};
+use ba2::{ByteSlice, Reader};
+use rayon::prelude::*;
+use std::collections::HashSet;
+use std::path::Path;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use tracing::debug;
+
+use super::BsaFileEntry;
+
+/// List all files in a TES3 (Morrowind) 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 TES3 BSA: {}", bsa_path.display()))?;
+
+ let mut files = Vec::new();
+
+ for (key, _file) in archive.iter() {
+ let path = String::from_utf8_lossy(key.name().as_bytes()).to_string();
+
+ files.push(BsaFileEntry { path });
+ }
+
+ debug!(
+ "Listed {} files in TES3 BSA {}",
+ files.len(),
+ bsa_path.display()
+ );
+ Ok(files)
+}
+
+/// Extract a single file from a TES3 (Morrowind) BSA archive
+#[allow(dead_code)]
+pub fn extract_file(bsa_path: &Path, file_path: &str) -> Result<Vec<u8>> {
+ let archive: Archive = Archive::read(bsa_path)
+ .with_context(|| format!("Failed to open TES3 BSA: {}", bsa_path.display()))?;
+
+ // Normalize path separators
+ let normalized = file_path.replace('/', "\\");
+
+ // Search case-insensitively
+ for (key, file) in archive.iter() {
+ let current_path = String::from_utf8_lossy(key.name().as_bytes());
+
+ if current_path.eq_ignore_ascii_case(&normalized) {
+ // TES3 BSAs are uncompressed, so just return the raw bytes
+ return Ok(file.as_bytes().to_vec());
+ }
+ }
+
+ bail!(
+ "File not found in TES3 BSA: {} (looking for '{}')",
+ bsa_path.display(),
+ file_path
+ )
+}
+
+/// Extract multiple files from a TES3 BSA archive in parallel.
+/// Opens the archive once, collects matching entries, then 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: Archive = Archive::read(bsa_path)
+ .with_context(|| format!("Failed to open TES3 BSA: {}", bsa_path.display()))?;
+
+ // Collect matching entries
+ let mut entries: Vec<(String, &Tes3File)> = Vec::new();
+ for (key, file) in archive.iter() {
+ let path = String::from_utf8_lossy(key.name().as_bytes()).to_string();
+ let lookup = path.to_lowercase();
+ if wanted.contains(&lookup) {
+ entries.push((path, file));
+ }
+ }
+
+ // Write in parallel
+ let extracted = AtomicUsize::new(0);
+ entries
+ .par_iter()
+ .try_for_each(|(path, file)| -> Result<()> {
+ callback(path, file.as_bytes().to_vec())?;
+ extracted.fetch_add(1, Ordering::Relaxed);
+ Ok(())
+ })?;
+
+ let count = extracted.load(Ordering::Relaxed);
+ debug!(
+ "Batch extracted {} of {} wanted files from TES3 BSA {}",
+ count,
+ wanted.len(),
+ bsa_path.display()
+ );
+ Ok(count)
+}
diff --git a/libs/bsa_ffi/src/archive/writer.rs b/libs/bsa_ffi/src/archive/writer.rs
new file mode 100644
index 0000000..112619f
--- /dev/null
+++ b/libs/bsa_ffi/src/archive/writer.rs
@@ -0,0 +1,256 @@
+//! BSA archive creation
+
+use anyhow::{bail, Context, Result};
+use ba2::tes4::{
+ Archive, ArchiveFlags, ArchiveKey, ArchiveOptions, ArchiveTypes, Directory, DirectoryKey,
+ File as BsaFile, FileCompressionOptions, Version,
+};
+use ba2::CompressableFrom;
+use rayon::prelude::*;
+use std::collections::HashMap;
+use std::fs;
+use std::io::BufWriter;
+use std::path::Path;
+use tracing::info;
+
+use super::{default_flags_fo3, default_flags_oblivion, detect_types, detect_version};
+
+/// Helper struct to hold file data with lifetime for BSA creation
+struct FileEntry {
+ dir_path: String,
+ file_name: String,
+ data: Vec<u8>,
+}
+
+impl FileEntry {
+ /// Create a BSA file, optionally compressing it
+ fn as_bsa_file(&self, version: Version, should_compress: bool) -> Result<BsaFile<'static>> {
+ // Create an uncompressed file from our raw data
+ let uncompressed = BsaFile::from_decompressed(self.data.clone().into_boxed_slice());
+
+ if should_compress {
+ // Compress the file using ba2's compress method
+ let compression_options = FileCompressionOptions::builder().version(version).build();
+
+ uncompressed
+ .compress(&compression_options)
+ .with_context(|| {
+ format!("Failed to compress: {}/{}", self.dir_path, self.file_name)
+ })
+ } else {
+ Ok(uncompressed)
+ }
+ }
+}
+
+/// Builder for creating BSA archives
+pub struct BsaBuilder {
+ /// Files organized by directory -> filename -> data
+ files: HashMap<String, HashMap<String, Vec<u8>>>,
+ flags: ArchiveFlags,
+ types: ArchiveTypes,
+ version: Version,
+}
+
+impl BsaBuilder {
+ pub fn new() -> Self {
+ Self {
+ files: HashMap::new(),
+ flags: default_flags_fo3(),
+ types: ArchiveTypes::empty(),
+ version: Version::v104,
+ }
+ }
+
+ /// Create builder with settings detected from BSA name
+ #[allow(dead_code)]
+ pub fn from_name(name: &str) -> Self {
+ let version = detect_version(name);
+ let types = detect_types(name);
+ let flags = if version == Version::v103 {
+ default_flags_oblivion()
+ } else {
+ default_flags_fo3()
+ };
+
+ Self {
+ files: HashMap::new(),
+ flags,
+ types,
+ version,
+ }
+ }
+
+ /// Set archive flags
+ #[allow(dead_code)]
+ pub fn with_flags(mut self, flags: ArchiveFlags) -> Self {
+ self.flags = flags;
+ self
+ }
+
+ /// Set archive types
+ #[allow(dead_code)]
+ pub fn with_types(mut self, types: ArchiveTypes) -> Self {
+ self.types = types;
+ self
+ }
+
+ /// Set BSA version
+ pub fn with_version(mut self, version: Version) -> Self {
+ self.version = version;
+ self
+ }
+
+ /// Enable or disable compression
+ pub fn with_compression(mut self, compress: bool) -> Self {
+ if compress {
+ self.flags |= ArchiveFlags::COMPRESSED;
+ } else {
+ self.flags &= !ArchiveFlags::COMPRESSED;
+ }
+ self
+ }
+
+ /// Add a file to the archive
+ pub fn add_file(&mut self, path: &str, data: Vec<u8>) {
+ // Normalize: forward slashes, strip leading slash
+ let normalized = path.replace('\\', "/");
+ let normalized = normalized.trim_start_matches('/');
+
+ let (dir_path, file_name) = if let Some(idx) = normalized.rfind('/') {
+ (
+ normalized[..idx].to_string(),
+ normalized[idx + 1..].to_string(),
+ )
+ } else {
+ (".".to_string(), normalized.to_string())
+ };
+
+ self.files
+ .entry(dir_path)
+ .or_default()
+ .insert(file_name, data);
+ }
+
+ /// Get number of files
+ pub fn file_count(&self) -> usize {
+ self.files.values().map(|d| d.len()).sum()
+ }
+
+ /// Check if empty
+ pub fn is_empty(&self) -> bool {
+ self.file_count() == 0
+ }
+
+ /// Build and write the BSA to disk with progress callback
+ pub fn build_with_progress<F>(self, output_path: &Path, progress: F) -> Result<()>
+ where
+ F: Fn(usize, usize, &str) + Send + Sync,
+ {
+ if self.is_empty() {
+ bail!("Cannot create empty BSA archive");
+ }
+
+ let file_count = self.file_count();
+ let total_size: u64 = self
+ .files
+ .values()
+ .flat_map(|files| files.values())
+ .map(|data| data.len() as u64)
+ .sum();
+
+ info!(
+ "Building BSA: {} ({} files, {} MB, version {:?}, flags {:?})",
+ output_path.display(),
+ file_count,
+ total_size / 1_000_000,
+ self.version,
+ self.flags
+ );
+
+ // Check if we should compress files
+ let should_compress = self.flags.contains(ArchiveFlags::COMPRESSED);
+
+ // Flatten to FileEntry structs that own their data
+ let entries: Vec<FileEntry> = self
+ .files
+ .into_iter()
+ .flat_map(|(dir_path, files)| {
+ files.into_iter().map(move |(file_name, data)| FileEntry {
+ dir_path: dir_path.clone(),
+ file_name,
+ data,
+ })
+ })
+ .collect();
+
+ let total = entries.len();
+ let processed_count = std::sync::atomic::AtomicUsize::new(0);
+
+ // Process files in parallel - create and compress BsaFile entries
+ let version = self.version;
+ let processed: Result<Vec<(String, String, BsaFile)>> = entries
+ .par_iter()
+ .map(|entry| {
+ let file = entry.as_bsa_file(version, should_compress)?;
+ let current =
+ processed_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
+ progress(
+ current,
+ total,
+ &format!("{}/{}", entry.dir_path, entry.file_name),
+ );
+ Ok((entry.dir_path.clone(), entry.file_name.clone(), file))
+ })
+ .collect();
+
+ let processed = processed?;
+
+ // Build archive
+ let mut archive = Archive::new();
+ for (dir_path, file_name, file) in processed {
+ let archive_key = ArchiveKey::from(dir_path.as_bytes());
+ let directory_key = DirectoryKey::from(file_name.as_bytes());
+
+ match archive.get_mut(&archive_key) {
+ Some(directory) => {
+ directory.insert(directory_key, file);
+ }
+ None => {
+ let mut directory = Directory::default();
+ directory.insert(directory_key, file);
+ archive.insert(archive_key, directory);
+ }
+ }
+ }
+
+ let options = ArchiveOptions::builder()
+ .version(self.version)
+ .flags(self.flags)
+ .types(self.types)
+ .build();
+
+ // Create parent directory
+ if let Some(parent) = output_path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+
+ // Write archive
+ let file = fs::File::create(output_path)
+ .with_context(|| format!("Failed to create BSA: {}", output_path.display()))?;
+ let mut writer = BufWriter::new(file);
+
+ archive
+ .write(&mut writer, &options)
+ .with_context(|| format!("Failed to write BSA: {}", output_path.display()))?;
+
+ info!("Created BSA: {}", output_path.display());
+ Ok(())
+ }
+}
+
+impl Default for BsaBuilder {
+ fn default() -> Self {
+ Self::new()
+ }
+}
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()),
+ }
+ }
+}