use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail, ensure};
use tracing::debug;
#[derive(Debug, Clone)]
pub struct ArchiveFileEntry {
pub path: String,
pub size: u64,
}
#[derive(Debug, Clone)]
pub struct ArchiveIndex {
pub archive_path: PathBuf,
pub files: Vec<ArchiveFileEntry>,
}
const BSA_MAGIC: &[u8; 4] = b"BSA\0";
const BA2_MAGIC: &[u8; 4] = b"BTDX";
impl ArchiveIndex {
pub fn read(path: &Path) -> Result<Self> {
let mut file = std::fs::File::open(path)
.with_context(|| format!("failed to open archive: {}", path.display()))?;
let mut magic = [0u8; 4];
file.read_exact(&mut magic)
.context("failed to read archive magic bytes")?;
let files = if &magic == BSA_MAGIC {
debug!(path = %path.display(), "reading BSA archive index");
read_bsa(&mut file)?
} else if &magic == BA2_MAGIC {
debug!(path = %path.display(), "reading BA2 archive index");
read_ba2(&mut file)?
} else {
bail!(
"unrecognised archive format (magic: {:?}) for {}",
magic,
path.display()
);
};
debug!(path = %path.display(), file_count = files.len(), "archive index read");
Ok(Self {
archive_path: path.to_path_buf(),
files,
})
}
}
struct BsaHeader {
version: u32,
_offset: u32,
_archive_flags: u32,
folder_count: u32,
file_count: u32,
_total_folder_name_length: u32,
_total_file_name_length: u32,
_file_flags: u32,
}
fn read_u32_le(r: &mut impl Read) -> Result<u32> {
let mut buf = [0u8; 4];
r.read_exact(&mut buf)?;
Ok(u32::from_le_bytes(buf))
}
fn read_u64_le(r: &mut impl Read) -> Result<u64> {
let mut buf = [0u8; 8];
r.read_exact(&mut buf)?;
Ok(u64::from_le_bytes(buf))
}
fn read_bsa_header(r: &mut impl Read) -> Result<BsaHeader> {
Ok(BsaHeader {
version: read_u32_le(r)?,
_offset: read_u32_le(r)?,
_archive_flags: read_u32_le(r)?,
folder_count: read_u32_le(r)?,
file_count: read_u32_le(r)?,
_total_folder_name_length: read_u32_le(r)?,
_total_file_name_length: read_u32_le(r)?,
_file_flags: read_u32_le(r)?,
})
}
fn read_null_terminated(r: &mut impl Read) -> Result<String> {
let mut buf = Vec::new();
let mut byte = [0u8; 1];
loop {
r.read_exact(&mut byte)?;
if byte[0] == 0 {
break;
}
buf.push(byte[0]);
}
Ok(String::from_utf8_lossy(&buf).into_owned())
}
fn read_bstring(r: &mut impl Read) -> Result<String> {
let mut len_buf = [0u8; 1];
r.read_exact(&mut len_buf)?;
let len = len_buf[0] as usize;
let mut buf = vec![0u8; len];
r.read_exact(&mut buf)?;
if buf.last() == Some(&0) {
buf.pop();
}
Ok(String::from_utf8_lossy(&buf).into_owned())
}
struct BsaFolderRecord {
_name_hash: u64,
file_count: u32,
_offset: u64,
}
struct BsaFileRecord {
_name_hash: u64,
size: u32,
_offset: u32,
}
fn read_bsa(file: &mut (impl Read + Seek)) -> Result<Vec<ArchiveFileEntry>> {
let header = read_bsa_header(file)?;
ensure!(
header.version == 104 || header.version == 105,
"unsupported BSA version: {} (expected 104 or 105)",
header.version
);
let folder_count = header.folder_count as usize;
let file_count = header.file_count as usize;
let mut folder_records = Vec::with_capacity(folder_count);
for _ in 0..folder_count {
let name_hash = read_u64_le(file)?;
let fc = read_u32_le(file)?;
let offset = if header.version == 105 {
let _padding = read_u32_le(file)?;
read_u64_le(file)?
} else {
read_u32_le(file)? as u64
};
folder_records.push(BsaFolderRecord {
_name_hash: name_hash,
file_count: fc,
_offset: offset,
});
}
let mut folder_names = Vec::with_capacity(folder_count);
let mut file_records_by_folder: Vec<Vec<BsaFileRecord>> = Vec::with_capacity(folder_count);
for folder_rec in &folder_records {
let folder_name = read_bstring(file)?;
folder_names.push(folder_name);
let mut records = Vec::with_capacity(folder_rec.file_count as usize);
for _ in 0..folder_rec.file_count {
let name_hash = read_u64_le(file)?;
let size = read_u32_le(file)?;
let offset = read_u32_le(file)?;
records.push(BsaFileRecord {
_name_hash: name_hash,
size: size & 0x3FFF_FFFF, _offset: offset,
});
}
file_records_by_folder.push(records);
}
let mut file_names = Vec::with_capacity(file_count);
for _ in 0..file_count {
file_names.push(read_null_terminated(file)?);
}
let mut entries = Vec::with_capacity(file_count);
let mut name_idx = 0usize;
for (folder_idx, records) in file_records_by_folder.iter().enumerate() {
let folder = &folder_names[folder_idx];
for rec in records {
if name_idx >= file_names.len() {
bail!("BSA file name index out of bounds");
}
let file_name = &file_names[name_idx];
name_idx += 1;
let path = normalize_path(&format!("{}/{}", folder, file_name));
entries.push(ArchiveFileEntry {
path,
size: rec.size as u64,
});
}
}
Ok(entries)
}
fn read_ba2(file: &mut (impl Read + Seek)) -> Result<Vec<ArchiveFileEntry>> {
let version = read_u32_le(file)?;
let mut type_buf = [0u8; 4];
file.read_exact(&mut type_buf)?;
let archive_type = std::str::from_utf8(&type_buf)
.context("invalid BA2 type string")?
.to_string();
let file_count = read_u32_le(file)? as usize;
let name_table_offset = read_u64_le(file)?;
debug!(version, archive_type = %archive_type, file_count, name_table_offset, "BA2 header");
let mut sizes = Vec::with_capacity(file_count);
match archive_type.as_str() {
"GNRL" => {
for _ in 0..file_count {
let _name_hash = read_u32_le(file)?;
let _ext = read_u32_le(file)?; let _dir_hash = read_u32_le(file)?;
let _unknown = read_u32_le(file)?; let _offset = read_u64_le(file)?;
let _packed_size = read_u32_le(file)?;
let unpacked_size = read_u32_le(file)?;
let _sentinel = read_u32_le(file)?; sizes.push(unpacked_size as u64);
}
}
"DX10" => {
for _ in 0..file_count {
let _name_hash = read_u32_le(file)?;
let _ext = read_u32_le(file)?;
let _dir_hash = read_u32_le(file)?;
let _unknown = read_u32_le(file)?; let _height = read_u32_le(file)?; let _mip_count = read_u32_le(file)?; let _dxgi_format = read_u32_le(file)?; let _tile_mode = read_u32_le(file)?;
sizes.push(0);
}
}
other => {
bail!("unsupported BA2 archive type: {other}");
}
}
file.seek(SeekFrom::Start(name_table_offset))?;
let mut entries = Vec::with_capacity(file_count);
for i in 0..file_count {
let mut len_buf = [0u8; 2];
file.read_exact(&mut len_buf)?;
let len = u16::from_le_bytes(len_buf) as usize;
let mut name_buf = vec![0u8; len];
file.read_exact(&mut name_buf)?;
let raw_name = String::from_utf8_lossy(&name_buf).into_owned();
let path = normalize_path(&raw_name);
entries.push(ArchiveFileEntry {
path,
size: sizes.get(i).copied().unwrap_or(0),
});
}
Ok(entries)
}
fn normalize_path(raw: &str) -> String {
let s = raw.replace('\\', "/").to_lowercase();
s.trim_start_matches('/').trim_start_matches("./").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn build_test_bsa(folders: &[(&str, &[(&str, u32)])]) -> Vec<u8> {
let mut buf = Vec::new();
let folder_count = folders.len() as u32;
let file_count: u32 = folders.iter().map(|(_, files)| files.len() as u32).sum();
let total_folder_name_len: u32 = folders
.iter()
.map(|(name, _)| name.len() as u32 + 2) .sum();
let total_file_name_len: u32 = folders
.iter()
.flat_map(|(_, files)| files.iter())
.map(|(name, _)| name.len() as u32 + 1) .sum();
buf.extend_from_slice(b"BSA\0");
buf.extend_from_slice(&105u32.to_le_bytes());
buf.extend_from_slice(&36u32.to_le_bytes());
buf.extend_from_slice(&0x03u32.to_le_bytes()); buf.extend_from_slice(&folder_count.to_le_bytes());
buf.extend_from_slice(&file_count.to_le_bytes());
buf.extend_from_slice(&total_folder_name_len.to_le_bytes());
buf.extend_from_slice(&total_file_name_len.to_le_bytes());
buf.extend_from_slice(&0u32.to_le_bytes());
for (_, files) in folders {
buf.extend_from_slice(&0u64.to_le_bytes()); buf.extend_from_slice(&(files.len() as u32).to_le_bytes());
buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&0u64.to_le_bytes()); }
for (folder_name, files) in folders {
let bstr_len = (folder_name.len() + 1) as u8; buf.push(bstr_len);
buf.extend_from_slice(folder_name.as_bytes());
buf.push(0);
for (_, size) in *files {
buf.extend_from_slice(&0u64.to_le_bytes()); buf.extend_from_slice(&size.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); }
}
for (_, files) in folders {
for (name, _) in *files {
buf.extend_from_slice(name.as_bytes());
buf.push(0);
}
}
buf
}
fn build_test_ba2(files: &[(&str, u32)]) -> Vec<u8> {
let mut buf = Vec::new();
let file_count = files.len() as u32;
buf.extend_from_slice(b"BTDX");
buf.extend_from_slice(&1u32.to_le_bytes());
buf.extend_from_slice(b"GNRL");
buf.extend_from_slice(&file_count.to_le_bytes());
let header_size: u64 = 24;
let records_size: u64 = file_count as u64 * 36;
let name_table_offset = header_size + records_size;
buf.extend_from_slice(&name_table_offset.to_le_bytes());
for (_, size) in files {
buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&0u64.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&size.to_le_bytes()); buf.extend_from_slice(&0xBAADF00Du32.to_le_bytes()); }
for (name, _) in files {
let len = name.len() as u16;
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(name.as_bytes());
}
buf
}
#[test]
fn parse_synthetic_bsa_v105() {
let data = build_test_bsa(&[
("textures", &[("sky.dds", 1024), ("ground.dds", 2048)]),
("meshes", &[("tree.nif", 512)]),
]);
let mut cursor = Cursor::new(&data);
cursor.set_position(4);
let entries = read_bsa(&mut cursor).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].path, "textures/sky.dds");
assert_eq!(entries[0].size, 1024);
assert_eq!(entries[1].path, "textures/ground.dds");
assert_eq!(entries[1].size, 2048);
assert_eq!(entries[2].path, "meshes/tree.nif");
assert_eq!(entries[2].size, 512);
}
#[test]
fn parse_synthetic_ba2_gnrl() {
let data = build_test_ba2(&[
("textures\\sky.dds", 1024),
("meshes\\tree.nif", 512),
]);
let mut cursor = Cursor::new(&data);
cursor.set_position(4);
let entries = read_ba2(&mut cursor).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].path, "textures/sky.dds");
assert_eq!(entries[0].size, 1024);
assert_eq!(entries[1].path, "meshes/tree.nif");
assert_eq!(entries[1].size, 512);
}
#[test]
fn normalize_path_handles_backslashes_and_case() {
assert_eq!(normalize_path("Textures\\Sky.DDS"), "textures/sky.dds");
assert_eq!(normalize_path("/textures/sky.dds"), "textures/sky.dds");
assert_eq!(normalize_path("./meshes/tree.nif"), "meshes/tree.nif");
}
#[test]
fn bad_magic_returns_error() {
let data = b"NOPE____";
let tmp = tempfile::NamedTempFile::new().unwrap();
std::io::Write::write_all(&mut tmp.as_file(), data).unwrap();
let result = ArchiveIndex::read(tmp.path());
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains("unrecognised archive format"));
}
#[test]
fn empty_bsa_parses() {
let data = build_test_bsa(&[]);
let mut cursor = Cursor::new(&data);
cursor.set_position(4);
let entries = read_bsa(&mut cursor).unwrap();
assert!(entries.is_empty());
}
#[test]
fn empty_ba2_parses() {
let data = build_test_ba2(&[]);
let mut cursor = Cursor::new(&data);
cursor.set_position(4);
let entries = read_ba2(&mut cursor).unwrap();
assert!(entries.is_empty());
}
}