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