use std::io::{Cursor, Write};
use std::path::Path;
use anyhow::{Context, Result, bail};
use flate2::Compression;
use flate2::write::ZlibEncoder;
use tokio::fs;
use tracing::{info, warn};
use modde_core::manifest::wabbajack::BSAFileState;
const BSA_MAGIC: &[u8; 4] = b"BSA\0";
const BSA_VERSION_SSE: u32 = 105;
const BSA_FLAG_HAS_FOLDER_NAMES: u32 = 1 << 0;
const BSA_FLAG_HAS_FILE_NAMES: u32 = 1 << 1;
const BSA_FLAG_COMPRESSED: u32 = 1 << 2;
const BA2_MAGIC: &[u8; 4] = b"BTDX";
const BA2_VERSION: u32 = 1;
const BA2_TYPE_GENERAL: &[u8; 4] = b"GNRL";
pub async fn create_bsa(
file_states: &[BSAFileState],
staging_dir: &Path,
output: &Path,
) -> Result<()> {
let extension = output
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bsa")
.to_lowercase();
match extension.as_str() {
"ba2" => create_ba2(file_states, staging_dir, output).await,
_ => create_bsa_inner(file_states, staging_dir, output).await,
}
}
async fn create_bsa_inner(
file_states: &[BSAFileState],
staging_dir: &Path,
output: &Path,
) -> Result<()> {
if file_states.is_empty() {
bail!("no file states provided for BSA creation");
}
let mut folders: std::collections::BTreeMap<String, Vec<&BSAFileState>> =
std::collections::BTreeMap::new();
for state in file_states {
let path = state.path.replace('/', "\\");
let folder = if let Some(pos) = path.rfind('\\') {
path[..pos].to_string()
} else {
String::new()
};
folders.entry(folder).or_default().push(state);
}
let mut file_data: Vec<(String, Vec<u8>)> = Vec::new();
for state in file_states {
let file_path = staging_dir.join(&state.path);
let data = if file_path.exists() {
fs::read(&file_path)
.await
.with_context(|| format!("failed to read file: {}", file_path.display()))?
} else {
warn!(path = %state.path, "file not found in staging, using empty data");
Vec::new()
};
file_data.push((state.path.clone(), data));
}
let mut compressed_data: Vec<Vec<u8>> = Vec::new();
for (_path, data) in &file_data {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(data)?;
let compressed = encoder.finish()?;
if compressed.len() < data.len() {
compressed_data.push(compressed);
} else {
compressed_data.push(data.clone());
}
}
let folder_count = folders.len() as u32;
let file_count = file_states.len() as u32;
let file_names: Vec<String> = file_states
.iter()
.map(|s| {
let path = s.path.replace('/', "\\");
if let Some(pos) = path.rfind('\\') {
path[pos + 1..].to_string()
} else {
path
}
})
.collect();
let total_file_name_length: u32 = file_names.iter().map(|n| n.len() as u32 + 1).sum();
let folder_names: Vec<&String> = folders.keys().collect();
let total_folder_name_length: u32 = folder_names
.iter()
.map(|n| {
if n.is_empty() {
2u32 } else {
n.len() as u32 + 2 }
})
.sum();
let mut buf = Cursor::new(Vec::new());
buf.write_all(BSA_MAGIC)?;
write_u32_le(&mut buf, BSA_VERSION_SSE)?;
write_u32_le(&mut buf, 36)?;
let flags = BSA_FLAG_HAS_FOLDER_NAMES | BSA_FLAG_HAS_FILE_NAMES | BSA_FLAG_COMPRESSED;
write_u32_le(&mut buf, flags)?;
write_u32_le(&mut buf, folder_count)?;
write_u32_le(&mut buf, file_count)?;
write_u32_le(&mut buf, total_folder_name_length)?;
write_u32_le(&mut buf, total_file_name_length)?;
write_u32_le(&mut buf, 0)?;
let folder_records_end = 36 + folder_count as u64 * 16;
let mut block_offset = folder_records_end;
for folder_name in &folder_names {
let file_count_in_folder = folders[*folder_name].len() as u32;
let name_hash = bsa_hash_folder(folder_name);
write_u64_le(&mut buf, name_hash)?;
write_u32_le(&mut buf, file_count_in_folder)?;
write_u32_le(&mut buf, block_offset as u32)?;
let name_len = if folder_name.is_empty() { 0 } else { folder_name.len() };
block_offset += 1 + name_len as u64 + 1 + file_count_in_folder as u64 * 16;
}
let file_name_block_offset = block_offset;
let file_data_start = file_name_block_offset + total_file_name_length as u64;
let mut data_offset = file_data_start;
let mut file_index = 0usize;
for folder_name in &folder_names {
let files_in_folder = &folders[*folder_name];
let name_bytes = if folder_name.is_empty() {
Vec::new()
} else {
folder_name.as_bytes().to_vec()
};
buf.write_all(&[name_bytes.len() as u8 + 1])?; buf.write_all(&name_bytes)?;
buf.write_all(&[0])?;
for _file_state in files_in_folder {
let file_name = &file_names[file_index];
let name_hash = bsa_hash_file(file_name);
let cdata = &compressed_data[file_index];
let data_size = cdata.len() as u32;
write_u64_le(&mut buf, name_hash)?;
write_u32_le(&mut buf, data_size)?;
write_u32_le(&mut buf, data_offset as u32)?;
data_offset += data_size as u64;
file_index += 1;
}
}
for name in &file_names {
buf.write_all(name.as_bytes())?;
buf.write_all(&[0])?;
}
for cdata in &compressed_data {
buf.write_all(cdata)?;
}
let bytes = buf.into_inner();
if let Some(parent) = output.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(output, &bytes).await?;
info!(
output = %output.display(),
file_count,
folder_count,
size = bytes.len(),
"BSA archive created"
);
Ok(())
}
async fn create_ba2(
file_states: &[BSAFileState],
staging_dir: &Path,
output: &Path,
) -> Result<()> {
if file_states.is_empty() {
bail!("no file states provided for BA2 creation");
}
let mut file_contents: Vec<Vec<u8>> = Vec::new();
for state in file_states {
let file_path = staging_dir.join(&state.path);
let data = if file_path.exists() {
fs::read(&file_path)
.await
.with_context(|| format!("failed to read file: {}", file_path.display()))?
} else {
warn!(path = %state.path, "file not found in staging, using empty data");
Vec::new()
};
file_contents.push(data);
}
let file_count = file_states.len() as u32;
let header_size = 24u64;
let records_size = file_count as u64 * 36;
let data_start = header_size + records_size;
let mut data_offset = data_start;
let mut offsets: Vec<u64> = Vec::new();
for content in &file_contents {
offsets.push(data_offset);
data_offset += content.len() as u64;
}
let name_table_offset = data_offset;
let mut buf = Cursor::new(Vec::new());
buf.write_all(BA2_MAGIC)?;
write_u32_le(&mut buf, BA2_VERSION)?;
buf.write_all(BA2_TYPE_GENERAL)?;
write_u32_le(&mut buf, file_count)?;
write_u64_le(&mut buf, name_table_offset)?;
for (i, state) in file_states.iter().enumerate() {
let path = state.path.replace('/', "\\");
let file_name = path.rsplit('\\').next().unwrap_or(&path);
let (name_part, ext_part) = if let Some(dot_pos) = file_name.rfind('.') {
(&file_name[..dot_pos], &file_name[dot_pos + 1..])
} else {
(file_name, "")
};
let dir_part = if let Some(pos) = path.rfind('\\') {
&path[..pos]
} else {
""
};
let name_hash = ba2_crc32(name_part.as_bytes());
let mut ext_bytes = [0u8; 4];
for (i, b) in ext_part.bytes().take(4).enumerate() {
ext_bytes[i] = b;
}
let dir_hash = ba2_crc32(dir_part.as_bytes());
write_u32_le(&mut buf, name_hash)?;
buf.write_all(&ext_bytes)?;
write_u32_le(&mut buf, dir_hash)?;
write_u32_le(&mut buf, 0)?; write_u64_le(&mut buf, offsets[i])?;
write_u32_le(&mut buf, 0)?; write_u32_le(&mut buf, file_contents[i].len() as u32)?;
}
for content in &file_contents {
buf.write_all(content)?;
}
for state in file_states {
let name = &state.path;
let name_bytes = name.as_bytes();
write_u16_le(&mut buf, name_bytes.len() as u16)?;
buf.write_all(name_bytes)?;
}
let bytes = buf.into_inner();
if let Some(parent) = output.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(output, &bytes).await?;
info!(
output = %output.display(),
file_count,
size = bytes.len(),
"BA2 archive created"
);
Ok(())
}
fn bsa_hash_folder(name: &str) -> u64 {
bsa_hash_path(&name.to_lowercase().replace('/', "\\"))
}
fn bsa_hash_file(name: &str) -> u64 {
let lower = name.to_lowercase();
bsa_hash_path(&lower)
}
fn bsa_hash_path(path: &str) -> u64 {
let bytes = path.as_bytes();
if bytes.is_empty() {
return 0;
}
let len = bytes.len();
let mut hash1: u32 = 0;
hash1 = hash1.wrapping_add(bytes[len - 1] as u32);
hash1 |= (if len >= 2 { bytes[len - 2] as u32 } else { 0 }) << 8;
hash1 |= (len as u32) << 16;
hash1 |= (bytes[0] as u32) << 24;
let mut hash2: u32 = 0;
if len > 2 {
for &b in &bytes[1..len - 2] {
hash2 = hash2.wrapping_mul(0x1003f).wrapping_add(b as u32);
}
}
let ext = if let Some(dot_pos) = path.rfind('.') {
&path[dot_pos..]
} else {
""
};
match ext {
".nif" => {
hash1 = hash1.wrapping_add(0x8080);
hash2 = hash2.wrapping_add(0x80);
}
".kf" => {
hash1 = hash1.wrapping_add(0x8000);
hash2 = hash2.wrapping_add(0x80);
}
".dds" => {
hash1 = hash1.wrapping_add(0x8800);
hash2 = hash2.wrapping_add(0x80);
}
".wav" => {
hash1 = hash1.wrapping_add(0x0080);
hash2 = hash2.wrapping_add(0x80);
}
_ => {}
}
((hash2 as u64) << 32) | (hash1 as u64)
}
fn ba2_crc32(data: &[u8]) -> u32 {
let mut hash: u32 = 0;
for &b in data {
hash = hash.wrapping_mul(31).wrapping_add(b.to_ascii_lowercase() as u32);
}
hash
}
fn write_u16_le<W: Write>(w: &mut W, v: u16) -> std::io::Result<()> {
w.write_all(&v.to_le_bytes())
}
fn write_u32_le<W: Write>(w: &mut W, v: u32) -> std::io::Result<()> {
w.write_all(&v.to_le_bytes())
}
fn write_u64_le<W: Write>(w: &mut W, v: u64) -> std::io::Result<()> {
w.write_all(&v.to_le_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
#[tokio::test]
async fn test_create_bsa_empty_errors() {
let staging = tempfile::tempdir().unwrap();
let output = staging.path().join("test.bsa");
let result = create_bsa(&[], staging.path(), &output).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_create_bsa_writes_magic() {
let staging = tempfile::tempdir().unwrap();
let output = staging.path().join("test.bsa");
let file_dir = staging.path().join("meshes");
tokio::fs::create_dir_all(&file_dir).await.unwrap();
tokio::fs::write(file_dir.join("test.nif"), b"fake nif data")
.await
.unwrap();
let states = vec![BSAFileState {
path: "meshes\\test.nif".to_string(),
hash: 0,
size: 13,
}];
create_bsa(&states, staging.path(), &output).await.unwrap();
let mut f = std::fs::File::open(&output).unwrap();
let mut magic = [0u8; 4];
f.read_exact(&mut magic).unwrap();
assert_eq!(&magic, BSA_MAGIC);
}
#[tokio::test]
async fn test_create_ba2_writes_magic() {
let staging = tempfile::tempdir().unwrap();
let output = staging.path().join("test.ba2");
let file_dir = staging.path().join("data");
tokio::fs::create_dir_all(&file_dir).await.unwrap();
tokio::fs::write(file_dir.join("test.txt"), b"test content")
.await
.unwrap();
let states = vec![BSAFileState {
path: "data\\test.txt".to_string(),
hash: 0,
size: 12,
}];
create_bsa(&states, staging.path(), &output).await.unwrap();
let mut f = std::fs::File::open(&output).unwrap();
let mut magic = [0u8; 4];
f.read_exact(&mut magic).unwrap();
assert_eq!(&magic, BA2_MAGIC);
}
#[test]
fn test_bsa_hash_not_zero() {
let hash = bsa_hash_file("test.nif");
assert_ne!(hash, 0);
}
#[test]
fn test_bsa_hash_deterministic() {
let h1 = bsa_hash_file("meshes\\armor.nif");
let h2 = bsa_hash_file("meshes\\armor.nif");
assert_eq!(h1, h2);
}
#[test]
fn test_bsa_hash_empty_string() {
assert_eq!(bsa_hash_path(""), 0);
assert_eq!(bsa_hash_file(""), 0);
assert_eq!(bsa_hash_folder(""), 0);
}
#[test]
fn test_bsa_hash_case_insensitive() {
let h1 = bsa_hash_file("Test.Nif");
let h2 = bsa_hash_file("test.nif");
assert_eq!(h1, h2);
}
#[test]
fn test_bsa_hash_extension_nif_adjusts() {
let with_ext = bsa_hash_path("model.nif");
let without_ext = bsa_hash_path("model");
assert_ne!(with_ext, without_ext);
}
#[test]
fn test_bsa_hash_extension_dds_adjusts() {
let with_ext = bsa_hash_path("texture.dds");
let without_ext = bsa_hash_path("texture");
assert_ne!(with_ext, without_ext);
}
#[test]
fn test_bsa_hash_extension_wav_adjusts() {
let with_ext = bsa_hash_path("sound.wav");
let without_ext = bsa_hash_path("sound");
assert_ne!(with_ext, without_ext);
}
#[test]
fn test_bsa_hash_extension_kf_adjusts() {
let with_ext = bsa_hash_path("anim.kf");
let without_ext = bsa_hash_path("anim");
assert_ne!(with_ext, without_ext);
}
#[test]
fn test_ba2_crc32_empty() {
assert_eq!(ba2_crc32(b""), 0);
}
#[test]
fn test_ba2_crc32_deterministic() {
let h1 = ba2_crc32(b"hello world");
let h2 = ba2_crc32(b"hello world");
assert_eq!(h1, h2);
}
#[test]
fn test_ba2_crc32_case_insensitive() {
let h1 = ba2_crc32(b"ABC");
let h2 = ba2_crc32(b"abc");
assert_eq!(h1, h2);
}
#[tokio::test]
async fn test_create_bsa_multiple_files_in_folder() {
let staging = tempfile::tempdir().unwrap();
let output = staging.path().join("test.bsa");
let file_dir = staging.path().join("meshes");
tokio::fs::create_dir_all(&file_dir).await.unwrap();
tokio::fs::write(file_dir.join("armor.nif"), b"armor data")
.await
.unwrap();
tokio::fs::write(file_dir.join("weapon.nif"), b"weapon data")
.await
.unwrap();
let states = vec![
BSAFileState {
path: "meshes\\armor.nif".to_string(),
hash: 0,
size: 10,
},
BSAFileState {
path: "meshes\\weapon.nif".to_string(),
hash: 0,
size: 11,
},
];
create_bsa(&states, staging.path(), &output).await.unwrap();
assert!(output.exists());
let mut f = std::fs::File::open(&output).unwrap();
let mut magic = [0u8; 4];
f.read_exact(&mut magic).unwrap();
assert_eq!(&magic, BSA_MAGIC);
}
#[tokio::test]
async fn test_create_bsa_multiple_folders() {
let staging = tempfile::tempdir().unwrap();
let output = staging.path().join("test.bsa");
let meshes_dir = staging.path().join("meshes");
let textures_dir = staging.path().join("textures");
tokio::fs::create_dir_all(&meshes_dir).await.unwrap();
tokio::fs::create_dir_all(&textures_dir).await.unwrap();
tokio::fs::write(meshes_dir.join("test.nif"), b"nif data")
.await
.unwrap();
tokio::fs::write(textures_dir.join("test.dds"), b"dds data")
.await
.unwrap();
let states = vec![
BSAFileState {
path: "meshes\\test.nif".to_string(),
hash: 0,
size: 8,
},
BSAFileState {
path: "textures\\test.dds".to_string(),
hash: 0,
size: 8,
},
];
create_bsa(&states, staging.path(), &output).await.unwrap();
assert!(output.exists());
let data = std::fs::read(&output).unwrap();
assert!(data.len() > 36); }
#[tokio::test]
async fn test_create_bsa_missing_file_uses_empty() {
let staging = tempfile::tempdir().unwrap();
let output = staging.path().join("test.bsa");
let states = vec![BSAFileState {
path: "meshes\\missing.nif".to_string(),
hash: 0,
size: 100,
}];
let result = create_bsa(&states, staging.path(), &output).await;
assert!(result.is_ok());
assert!(output.exists());
}
#[tokio::test]
async fn test_create_ba2_multiple_files() {
let staging = tempfile::tempdir().unwrap();
let output = staging.path().join("test.ba2");
let data_dir = staging.path().join("data");
tokio::fs::create_dir_all(&data_dir).await.unwrap();
tokio::fs::write(data_dir.join("file1.txt"), b"content one")
.await
.unwrap();
tokio::fs::write(data_dir.join("file2.txt"), b"content two")
.await
.unwrap();
let states = vec![
BSAFileState {
path: "data\\file1.txt".to_string(),
hash: 0,
size: 11,
},
BSAFileState {
path: "data\\file2.txt".to_string(),
hash: 0,
size: 11,
},
];
create_bsa(&states, staging.path(), &output).await.unwrap();
let mut f = std::fs::File::open(&output).unwrap();
let mut magic = [0u8; 4];
f.read_exact(&mut magic).unwrap();
assert_eq!(&magic, BA2_MAGIC);
}
#[tokio::test]
async fn test_create_bsa_forward_slash_normalization() {
let staging = tempfile::tempdir().unwrap();
let output = staging.path().join("test.bsa");
let file_dir = staging.path().join("meshes");
tokio::fs::create_dir_all(&file_dir).await.unwrap();
tokio::fs::write(file_dir.join("test.nif"), b"data")
.await
.unwrap();
let states = vec![BSAFileState {
path: "meshes/test.nif".to_string(),
hash: 0,
size: 4,
}];
let result = create_bsa(&states, staging.path(), &output).await;
assert!(result.is_ok());
assert!(output.exists());
}
#[test]
fn test_bsa_hash_file_known_output() {
let hash = bsa_hash_file("test.nif");
let hash2 = bsa_hash_file("test.nif");
assert_eq!(hash, hash2);
assert_ne!(hash, 0);
assert_eq!(hash, bsa_hash_path("test.nif"));
}
#[test]
fn test_bsa_hash_folder_known_output() {
let hash = bsa_hash_folder("meshes\\armor");
let hash2 = bsa_hash_folder("meshes\\armor");
assert_eq!(hash, hash2);
assert_ne!(hash, 0);
}
#[test]
fn test_bsa_hash_nif_extension_specific_adjustment() {
let nif = bsa_hash_path("model.nif");
let txt = bsa_hash_path("model.txt");
assert_ne!(nif, txt);
assert_ne!(nif, 0);
assert_eq!(nif, bsa_hash_path("model.nif"));
}
#[test]
fn test_bsa_hash_kf_extension_specific_adjustment() {
let kf = bsa_hash_path("anim.kf");
let txt = bsa_hash_path("anim.txt");
assert_ne!(kf, txt);
assert_ne!(kf, 0);
assert_eq!(kf, bsa_hash_path("anim.kf"));
}
#[test]
fn test_bsa_hash_dds_extension_specific_adjustment() {
let dds = bsa_hash_path("texture.dds");
let txt = bsa_hash_path("texture.txt");
assert_ne!(dds, txt);
assert_ne!(dds, 0);
assert_eq!(dds, bsa_hash_path("texture.dds"));
}
#[test]
fn test_bsa_hash_wav_extension_specific_adjustment() {
let wav = bsa_hash_path("sound.wav");
let txt = bsa_hash_path("sound.txt");
assert_ne!(wav, txt);
assert_ne!(wav, 0);
assert_eq!(wav, bsa_hash_path("sound.wav"));
}
#[test]
fn test_bsa_hash_all_special_extensions_distinct() {
let nif = bsa_hash_path("file.nif");
let kf = bsa_hash_path("file.kf");
let dds = bsa_hash_path("file.dds");
let wav = bsa_hash_path("file.wav");
let txt = bsa_hash_path("file.txt");
let hashes = [nif, kf, dds, wav, txt];
for i in 0..hashes.len() {
for j in (i + 1)..hashes.len() {
assert_ne!(hashes[i], hashes[j], "hash collision between index {i} and {j}");
}
}
}
#[test]
fn test_bsa_hash_unknown_extension_no_adjustment() {
let base = bsa_hash_path("file");
let txt = bsa_hash_path("file.txt");
assert_ne!(base, txt);
}
#[test]
fn test_ba2_crc32_known_value() {
let hash = ba2_crc32(b"test");
let hash2 = ba2_crc32(b"test");
assert_eq!(hash, hash2);
assert_ne!(hash, 0);
}
#[test]
fn test_ba2_crc32_single_byte() {
let h = ba2_crc32(b"a");
assert_eq!(h, b'a' as u32); }
#[test]
fn test_ba2_crc32_two_bytes() {
let h = ba2_crc32(b"ab");
assert_eq!(h, 97u32 * 31 + 98);
}
#[test]
fn test_bsa_hash_folder_normalizes_slashes() {
let h1 = bsa_hash_folder("meshes/armor");
let h2 = bsa_hash_folder("meshes\\armor");
assert_eq!(h1, h2);
}
#[test]
fn test_bsa_hash_path_single_char() {
let hash = bsa_hash_path("a");
assert_ne!(hash, 0);
let expected_lo = (b'a' as u32) | (1u32 << 16) | ((b'a' as u32) << 24);
assert_eq!(hash as u32, expected_lo);
}
#[test]
fn test_bsa_hash_path_two_chars() {
let hash = bsa_hash_path("ab");
assert_ne!(hash, 0);
let expected_lo =
(b'b' as u32) | ((b'a' as u32) << 8) | (2u32 << 16) | ((b'a' as u32) << 24);
assert_eq!(hash as u32, expected_lo);
assert_eq!(hash >> 32, 0); }
#[test]
fn test_bsa_hash_different_filenames() {
let h1 = bsa_hash_file("armor.nif");
let h2 = bsa_hash_file("weapon.nif");
assert_ne!(h1, h2);
}
#[test]
fn test_bsa_hash_different_folders() {
let h1 = bsa_hash_folder("meshes");
let h2 = bsa_hash_folder("textures");
assert_ne!(h1, h2);
}
#[test]
fn test_ba2_crc32_different_inputs() {
let h1 = ba2_crc32(b"meshes");
let h2 = ba2_crc32(b"textures");
assert_ne!(h1, h2);
}
}