diff options
Diffstat (limited to 'libs/bsa_ffi/src/archive/mod.rs')
| -rw-r--r-- | libs/bsa_ffi/src/archive/mod.rs | 436 |
1 files changed, 436 insertions, 0 deletions
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 + } +} |
