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 | |
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')
| -rw-r--r-- | libs/bsa_ffi/CMakeLists.txt | 34 | ||||
| -rw-r--r-- | libs/bsa_ffi/Cargo.lock | 520 | ||||
| -rw-r--r-- | libs/bsa_ffi/Cargo.toml | 18 | ||||
| -rw-r--r-- | libs/bsa_ffi/include/bsa_ffi.h | 54 | ||||
| -rw-r--r-- | libs/bsa_ffi/src/archive/ba2_reader.rs | 125 | ||||
| -rw-r--r-- | libs/bsa_ffi/src/archive/ba2_writer.rs | 336 | ||||
| -rw-r--r-- | libs/bsa_ffi/src/archive/mod.rs | 436 | ||||
| -rw-r--r-- | libs/bsa_ffi/src/archive/reader.rs | 154 | ||||
| -rw-r--r-- | libs/bsa_ffi/src/archive/tes3_reader.rs | 104 | ||||
| -rw-r--r-- | libs/bsa_ffi/src/archive/writer.rs | 256 | ||||
| -rw-r--r-- | libs/bsa_ffi/src/lib.rs | 325 |
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()), + } + } +} |
