Skip to main content

modde_core/
bethesda_archive.rs

1//! BSA/BA2 archive table-of-contents and extraction helpers.
2//!
3//! The reader supports Bethesda BSA v104/v105 and BA2 `GNRL` archives. `DX10`
4//! texture BA2 archives are indexed but intentionally not extracted yet.
5
6use std::io::{Read, Seek, SeekFrom};
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result, bail, ensure};
10use flate2::read::ZlibDecoder;
11use tracing::debug;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14enum ArchiveFormat {
15    Bsa,
16    Ba2Gnrl,
17    Ba2Dx10,
18}
19
20/// A single file entry within an archive.
21#[derive(Debug, Clone)]
22pub struct ArchiveFileEntry {
23    /// Normalized forward-slash relative path (e.g. `textures/sky.dds`).
24    pub path: String,
25    /// Uncompressed file size in bytes.
26    pub size: u64,
27    offset: u64,
28    packed_size: u64,
29    compressed: bool,
30    bsa_version: u32,
31    format: ArchiveFormat,
32    embedded_name: bool,
33}
34
35/// Table-of-contents for a BSA or BA2 archive.
36#[derive(Debug, Clone)]
37pub struct ArchiveIndex {
38    /// Path to the archive on disk.
39    pub archive_path: PathBuf,
40    /// All files listed in the archive.
41    pub files: Vec<ArchiveFileEntry>,
42}
43
44const BSA_MAGIC: &[u8; 4] = b"BSA\0";
45const BA2_MAGIC: &[u8; 4] = b"BTDX";
46const BSA_ARCHIVE_COMPRESSED: u32 = 1 << 2;
47const BSA_EMBED_FILE_NAMES: u32 = 1 << 8;
48const BSA_SIZE_COMPRESS_TOGGLE: u32 = 0x4000_0000;
49const BSA_SIZE_MASK: u32 = 0x3FFF_FFFF;
50
51impl ArchiveIndex {
52    /// Return true when the file has a Bethesda BSA/BA2 magic header.
53    pub fn has_bethesda_magic(path: &Path) -> std::io::Result<bool> {
54        let mut file = std::fs::File::open(path)?;
55        let mut magic = [0u8; 4];
56        file.read_exact(&mut magic)?;
57        Ok(&magic == BSA_MAGIC || &magic == BA2_MAGIC)
58    }
59
60    /// Read the file listing from a BSA or BA2 archive.
61    ///
62    /// Auto-detects the format from the magic bytes at the start of the file.
63    pub fn read(path: &Path) -> Result<Self> {
64        let mut file = std::fs::File::open(path)
65            .with_context(|| format!("failed to open archive: {}", path.display()))?;
66
67        let mut magic = [0u8; 4];
68        file.read_exact(&mut magic)
69            .context("failed to read archive magic bytes")?;
70
71        let files = if &magic == BSA_MAGIC {
72            debug!(path = %path.display(), "reading BSA archive index");
73            read_bsa(&mut file)?
74        } else if &magic == BA2_MAGIC {
75            debug!(path = %path.display(), "reading BA2 archive index");
76            read_ba2(&mut file)?
77        } else {
78            bail!(
79                "unrecognised archive format (magic: {:?}) for {}",
80                magic,
81                path.display()
82            );
83        };
84
85        debug!(path = %path.display(), file_count = files.len(), "archive index read");
86
87        Ok(Self {
88            archive_path: path.to_path_buf(),
89            files,
90        })
91    }
92
93    /// Extract a single file from the archive using case-insensitive path matching.
94    pub fn extract_file(&self, path: &str) -> Result<Vec<u8>> {
95        let normalized = normalize_path(path);
96        let entry = self.find_entry(path, &normalized)?;
97
98        let mut file = std::fs::File::open(&self.archive_path)
99            .with_context(|| format!("failed to open archive: {}", self.archive_path.display()))?;
100        extract_entry(&mut file, entry)
101    }
102
103    /// Extract a single file into a writer using case-insensitive path matching.
104    pub fn extract_file_to_writer(
105        &self,
106        path: &str,
107        writer: &mut impl std::io::Write,
108    ) -> Result<()> {
109        let normalized = normalize_path(path);
110        let entry = self.find_entry(path, &normalized)?;
111        let mut file = std::fs::File::open(&self.archive_path)
112            .with_context(|| format!("failed to open archive: {}", self.archive_path.display()))?;
113        extract_entry_to_writer(&mut file, entry, writer)
114    }
115
116    fn find_entry(&self, raw_path: &str, normalized: &str) -> Result<&ArchiveFileEntry> {
117        self.files
118            .iter()
119            .find(|entry| entry.path.eq_ignore_ascii_case(normalized))
120            .ok_or_else(|| {
121                anyhow::anyhow!(
122                    "file '{}' not found in Bethesda archive {}",
123                    raw_path,
124                    self.archive_path.display()
125                )
126            })
127    }
128}
129
130fn read_u16_le(r: &mut impl Read) -> Result<u16> {
131    let mut buf = [0u8; 2];
132    r.read_exact(&mut buf)?;
133    Ok(u16::from_le_bytes(buf))
134}
135
136fn read_u32_le(r: &mut impl Read) -> Result<u32> {
137    let mut buf = [0u8; 4];
138    r.read_exact(&mut buf)?;
139    Ok(u32::from_le_bytes(buf))
140}
141
142fn read_u64_le(r: &mut impl Read) -> Result<u64> {
143    let mut buf = [0u8; 8];
144    r.read_exact(&mut buf)?;
145    Ok(u64::from_le_bytes(buf))
146}
147
148fn read_null_terminated(r: &mut impl Read) -> Result<String> {
149    let mut buf = Vec::new();
150    let mut byte = [0u8; 1];
151    loop {
152        r.read_exact(&mut byte)?;
153        if byte[0] == 0 {
154            break;
155        }
156        buf.push(byte[0]);
157    }
158    Ok(String::from_utf8_lossy(&buf).into_owned())
159}
160
161fn read_bstring(r: &mut impl Read) -> Result<String> {
162    let len = read_u8(r)? as usize;
163    let mut buf = vec![0u8; len];
164    r.read_exact(&mut buf)?;
165    if buf.last() == Some(&0) {
166        buf.pop();
167    }
168    Ok(String::from_utf8_lossy(&buf).into_owned())
169}
170
171fn read_u8(r: &mut impl Read) -> Result<u8> {
172    let mut buf = [0u8; 1];
173    r.read_exact(&mut buf)?;
174    Ok(buf[0])
175}
176
177struct BsaHeader {
178    version: u32,
179    archive_flags: u32,
180    folder_count: u32,
181    file_count: u32,
182}
183
184struct BsaFolderRecord {
185    file_count: u32,
186}
187
188struct BsaFileRecord {
189    size_flags: u32,
190    offset: u32,
191}
192
193fn read_bsa_header(r: &mut impl Read) -> Result<BsaHeader> {
194    let version = read_u32_le(r)?;
195    let _offset = read_u32_le(r)?;
196    let archive_flags = read_u32_le(r)?;
197    let folder_count = read_u32_le(r)?;
198    let file_count = read_u32_le(r)?;
199    let _total_folder_name_length = read_u32_le(r)?;
200    let _total_file_name_length = read_u32_le(r)?;
201    let _file_flags = read_u32_le(r)?;
202
203    Ok(BsaHeader {
204        version,
205        archive_flags,
206        folder_count,
207        file_count,
208    })
209}
210
211fn read_bsa(file: &mut (impl Read + Seek)) -> Result<Vec<ArchiveFileEntry>> {
212    let header = read_bsa_header(file)?;
213    ensure!(
214        header.version == 104 || header.version == 105,
215        "unsupported BSA version: {} (expected 104 or 105)",
216        header.version
217    );
218
219    let folder_count = header.folder_count as usize;
220    let file_count = header.file_count as usize;
221    let default_compressed = header.archive_flags & BSA_ARCHIVE_COMPRESSED != 0;
222    let embedded_name = header.archive_flags & BSA_EMBED_FILE_NAMES != 0;
223
224    let mut folder_records = Vec::with_capacity(folder_count);
225    for _ in 0..folder_count {
226        let _name_hash = read_u64_le(file)?;
227        let file_count = read_u32_le(file)?;
228        if header.version == 105 {
229            let _padding = read_u32_le(file)?;
230            let _offset = read_u64_le(file)?;
231        } else {
232            let _offset = read_u32_le(file)?;
233        }
234        folder_records.push(BsaFolderRecord { file_count });
235    }
236
237    let mut folder_names = Vec::with_capacity(folder_count);
238    let mut file_records_by_folder: Vec<Vec<BsaFileRecord>> = Vec::with_capacity(folder_count);
239    for folder_rec in &folder_records {
240        folder_names.push(read_bstring(file)?);
241
242        let mut records = Vec::with_capacity(folder_rec.file_count as usize);
243        for _ in 0..folder_rec.file_count {
244            let _name_hash = read_u64_le(file)?;
245            let size_flags = read_u32_le(file)?;
246            let offset = read_u32_le(file)?;
247            records.push(BsaFileRecord { size_flags, offset });
248        }
249        file_records_by_folder.push(records);
250    }
251
252    let mut file_names = Vec::with_capacity(file_count);
253    for _ in 0..file_count {
254        file_names.push(read_null_terminated(file)?);
255    }
256
257    let mut entries = Vec::with_capacity(file_count);
258    let mut name_idx = 0usize;
259    for (folder_idx, records) in file_records_by_folder.iter().enumerate() {
260        let folder = &folder_names[folder_idx];
261        for rec in records {
262            if name_idx >= file_names.len() {
263                bail!("BSA file name index out of bounds");
264            }
265            let file_name = &file_names[name_idx];
266            name_idx += 1;
267
268            let toggle_compression = rec.size_flags & BSA_SIZE_COMPRESS_TOGGLE != 0;
269            let compressed = default_compressed ^ toggle_compression;
270            let packed_size = u64::from(rec.size_flags & BSA_SIZE_MASK);
271            let size = if compressed { 0 } else { packed_size };
272
273            entries.push(ArchiveFileEntry {
274                path: normalize_path(&format!("{folder}/{file_name}")),
275                size,
276                offset: u64::from(rec.offset),
277                packed_size,
278                compressed,
279                bsa_version: header.version,
280                format: ArchiveFormat::Bsa,
281                embedded_name,
282            });
283        }
284    }
285
286    populate_bsa_compressed_sizes(file, &mut entries)?;
287
288    Ok(entries)
289}
290
291fn populate_bsa_compressed_sizes(
292    file: &mut (impl Read + Seek),
293    entries: &mut [ArchiveFileEntry],
294) -> Result<()> {
295    for entry in entries.iter_mut().filter(|entry| entry.compressed) {
296        file.seek(SeekFrom::Start(entry.offset))?;
297        let mut remaining = entry.packed_size;
298
299        if entry.embedded_name {
300            let name_len = u64::from(read_u8(file)?);
301            file.seek(SeekFrom::Current(name_len as i64))?;
302            remaining = remaining
303                .checked_sub(name_len + 1)
304                .context("BSA entry embedded filename exceeds entry size")?;
305        }
306
307        ensure!(
308            remaining >= 4,
309            "compressed BSA entry '{}' is missing an uncompressed size prefix",
310            entry.path
311        );
312        entry.size = u64::from(read_u32_le(file)?);
313    }
314
315    Ok(())
316}
317
318fn read_ba2(file: &mut (impl Read + Seek)) -> Result<Vec<ArchiveFileEntry>> {
319    let _version = read_u32_le(file)?;
320    let mut type_buf = [0u8; 4];
321    file.read_exact(&mut type_buf)?;
322    let archive_type = std::str::from_utf8(&type_buf)
323        .context("invalid BA2 type string")?
324        .to_string();
325    let file_count = read_u32_le(file)? as usize;
326    let name_table_offset = read_u64_le(file)?;
327
328    let mut records = Vec::with_capacity(file_count);
329    match archive_type.as_str() {
330        "GNRL" => {
331            for _ in 0..file_count {
332                let _name_hash = read_u32_le(file)?;
333                let _ext = read_u32_le(file)?;
334                let _dir_hash = read_u32_le(file)?;
335                let _unknown = read_u32_le(file)?;
336                let offset = read_u64_le(file)?;
337                let packed_size = read_u32_le(file)?;
338                let unpacked_size = read_u32_le(file)?;
339                let _sentinel = read_u32_le(file)?;
340                records.push((offset, packed_size, unpacked_size, ArchiveFormat::Ba2Gnrl));
341            }
342        }
343        "DX10" => {
344            for _ in 0..file_count {
345                let _name_hash = read_u32_le(file)?;
346                let _ext = read_u32_le(file)?;
347                let _dir_hash = read_u32_le(file)?;
348                let _unknown = read_u32_le(file)?;
349                let _height = read_u32_le(file)?;
350                let _mip_count = read_u32_le(file)?;
351                let _dxgi_format = read_u32_le(file)?;
352                let _tile_mode = read_u32_le(file)?;
353                records.push((0, 0, 0, ArchiveFormat::Ba2Dx10));
354            }
355        }
356        other => bail!("unsupported BA2 archive type: {other}"),
357    }
358
359    file.seek(SeekFrom::Start(name_table_offset))?;
360
361    let mut entries = Vec::with_capacity(file_count);
362    for (offset, packed_size, unpacked_size, format) in records {
363        let len = read_u16_le(file)? as usize;
364        let mut name_buf = vec![0u8; len];
365        file.read_exact(&mut name_buf)?;
366        let path = normalize_path(&String::from_utf8_lossy(&name_buf));
367        let compressed = format == ArchiveFormat::Ba2Gnrl && packed_size != 0;
368        entries.push(ArchiveFileEntry {
369            path,
370            size: u64::from(unpacked_size),
371            offset,
372            packed_size: if packed_size == 0 {
373                u64::from(unpacked_size)
374            } else {
375                u64::from(packed_size)
376            },
377            compressed,
378            bsa_version: 0,
379            format,
380            embedded_name: false,
381        });
382    }
383
384    Ok(entries)
385}
386
387fn extract_entry(file: &mut (impl Read + Seek), entry: &ArchiveFileEntry) -> Result<Vec<u8>> {
388    match entry.format {
389        ArchiveFormat::Bsa => extract_bsa_entry(file, entry),
390        ArchiveFormat::Ba2Gnrl => extract_ba2_gnrl_entry(file, entry),
391        ArchiveFormat::Ba2Dx10 => bail!(
392            "BA2 DX10 texture extraction is not supported for '{}'",
393            entry.path
394        ),
395    }
396}
397
398fn extract_entry_to_writer(
399    file: &mut (impl Read + Seek),
400    entry: &ArchiveFileEntry,
401    writer: &mut impl std::io::Write,
402) -> Result<()> {
403    match entry.format {
404        ArchiveFormat::Bsa => extract_bsa_entry_to_writer(file, entry, writer),
405        ArchiveFormat::Ba2Gnrl => extract_ba2_gnrl_entry_to_writer(file, entry, writer),
406        ArchiveFormat::Ba2Dx10 => bail!(
407            "BA2 DX10 texture extraction is not supported for '{}'",
408            entry.path
409        ),
410    }
411}
412
413fn extract_bsa_entry(file: &mut (impl Read + Seek), entry: &ArchiveFileEntry) -> Result<Vec<u8>> {
414    file.seek(SeekFrom::Start(entry.offset))?;
415
416    let mut remaining = entry.packed_size;
417    if entry.embedded_name {
418        let name_len = u64::from(read_u8(file)?);
419        let mut skip = vec![0u8; name_len as usize];
420        file.read_exact(&mut skip)?;
421        remaining = remaining
422            .checked_sub(name_len + 1)
423            .context("BSA entry embedded filename exceeds entry size")?;
424    }
425
426    if entry.compressed {
427        let expected_size = read_u32_le(file)?;
428        remaining = remaining
429            .checked_sub(4)
430            .context("compressed BSA entry missing uncompressed size prefix")?;
431        let mut packed = vec![0u8; remaining as usize];
432        file.read_exact(&mut packed)?;
433        let data = decompress_bsa_payload(entry, &packed, expected_size as usize)?;
434        ensure!(
435            data.len() == expected_size as usize,
436            "decompressed BSA entry '{}' size mismatch: expected {}, got {}",
437            entry.path,
438            expected_size,
439            data.len()
440        );
441        Ok(data)
442    } else {
443        let mut data = vec![0u8; remaining as usize];
444        file.read_exact(&mut data)?;
445        Ok(data)
446    }
447}
448
449fn extract_bsa_entry_to_writer(
450    file: &mut (impl Read + Seek),
451    entry: &ArchiveFileEntry,
452    writer: &mut impl std::io::Write,
453) -> Result<()> {
454    file.seek(SeekFrom::Start(entry.offset))?;
455
456    let mut remaining = entry.packed_size;
457    if entry.embedded_name {
458        let name_len = u64::from(read_u8(file)?);
459        std::io::copy(&mut (&mut *file).take(name_len), &mut std::io::sink())?;
460        remaining = remaining
461            .checked_sub(name_len + 1)
462            .context("BSA entry embedded filename exceeds entry size")?;
463    }
464
465    if entry.compressed {
466        let expected_size = read_u32_le(file)?;
467        remaining = remaining
468            .checked_sub(4)
469            .context("compressed BSA entry missing uncompressed size prefix")?;
470        let mut packed = vec![0u8; remaining as usize];
471        file.read_exact(&mut packed)?;
472        let data = decompress_bsa_payload(entry, &packed, expected_size as usize)?;
473        writer.write_all(&data)?;
474        let written = data.len() as u64;
475        ensure!(
476            written == u64::from(expected_size),
477            "decompressed BSA entry '{}' size mismatch: expected {}, got {}",
478            entry.path,
479            expected_size,
480            written
481        );
482    } else {
483        let written = std::io::copy(&mut (&mut *file).take(remaining), writer)?;
484        ensure!(
485            written == remaining,
486            "BSA entry '{}' size mismatch: expected {}, got {}",
487            entry.path,
488            remaining,
489            written
490        );
491    }
492    writer.flush()?;
493    Ok(())
494}
495
496fn extract_ba2_gnrl_entry(
497    file: &mut (impl Read + Seek),
498    entry: &ArchiveFileEntry,
499) -> Result<Vec<u8>> {
500    file.seek(SeekFrom::Start(entry.offset))?;
501    let mut data = vec![0u8; entry.packed_size as usize];
502    file.read_exact(&mut data)?;
503
504    if !entry.compressed {
505        return Ok(data);
506    }
507
508    let mut decoder = ZlibDecoder::new(&data[..]);
509    let mut unpacked = Vec::with_capacity(entry.size as usize);
510    decoder.read_to_end(&mut unpacked)?;
511    ensure!(
512        unpacked.len() == entry.size as usize,
513        "decompressed BA2 entry '{}' size mismatch: expected {}, got {}",
514        entry.path,
515        entry.size,
516        unpacked.len()
517    );
518    Ok(unpacked)
519}
520
521fn extract_ba2_gnrl_entry_to_writer(
522    file: &mut (impl Read + Seek),
523    entry: &ArchiveFileEntry,
524    writer: &mut impl std::io::Write,
525) -> Result<()> {
526    file.seek(SeekFrom::Start(entry.offset))?;
527
528    if !entry.compressed {
529        let written = std::io::copy(&mut (&mut *file).take(entry.packed_size), writer)?;
530        ensure!(
531            written == entry.packed_size,
532            "BA2 entry '{}' size mismatch: expected {}, got {}",
533            entry.path,
534            entry.packed_size,
535            written
536        );
537        writer.flush()?;
538        return Ok(());
539    }
540
541    let mut data = vec![0u8; entry.packed_size as usize];
542    file.read_exact(&mut data)?;
543    let mut decoder = ZlibDecoder::new(&data[..]);
544    let written = std::io::copy(&mut decoder, writer)?;
545    ensure!(
546        written == entry.size,
547        "decompressed BA2 entry '{}' size mismatch: expected {}, got {}",
548        entry.path,
549        entry.size,
550        written
551    );
552    writer.flush()?;
553    Ok(())
554}
555
556fn decompress_bsa_payload(
557    entry: &ArchiveFileEntry,
558    packed: &[u8],
559    expected_size: usize,
560) -> Result<Vec<u8>> {
561    if entry.bsa_version >= 105 {
562        if packed.starts_with(&[0x04, 0x22, 0x4d, 0x18]) {
563            let mut decoder = lz4_flex::frame::FrameDecoder::new(packed);
564            let mut data = Vec::with_capacity(expected_size);
565            decoder.read_to_end(&mut data).with_context(|| {
566                format!("failed to LZ4-frame-decompress BSA entry '{}'", entry.path)
567            })?;
568            Ok(data)
569        } else {
570            lz4_flex::block::decompress(packed, expected_size)
571                .with_context(|| format!("failed to LZ4-decompress BSA entry '{}'", entry.path))
572        }
573    } else {
574        let mut decoder = ZlibDecoder::new(packed);
575        let mut data = Vec::with_capacity(expected_size);
576        decoder
577            .read_to_end(&mut data)
578            .with_context(|| format!("failed to zlib-decompress BSA entry '{}'", entry.path))?;
579        Ok(data)
580    }
581}
582
583/// Normalize a file path: lowercase, forward slashes, strip leading slash/dot.
584#[must_use]
585pub fn normalize_path(raw: &str) -> String {
586    let s = raw.replace('\\', "/").to_lowercase();
587    s.trim_start_matches('/')
588        .trim_start_matches("./")
589        .to_string()
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use flate2::Compression;
596    use flate2::write::ZlibEncoder;
597    use std::io::{Cursor, Write};
598
599    fn zlib(data: &[u8]) -> Vec<u8> {
600        let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
601        encoder.write_all(data).unwrap();
602        encoder.finish().unwrap()
603    }
604
605    fn lz4_frame(data: &[u8]) -> Vec<u8> {
606        let mut encoder = lz4_flex::frame::FrameEncoder::new(Vec::new());
607        encoder.write_all(data).unwrap();
608        encoder.finish().unwrap()
609    }
610
611    fn write_temp(data: &[u8]) -> tempfile::NamedTempFile {
612        let mut tmp = tempfile::NamedTempFile::new().unwrap();
613        tmp.write_all(data).unwrap();
614        tmp
615    }
616
617    fn build_test_bsa(folders: &[(&str, &[(&str, &[u8], bool)])]) -> Vec<u8> {
618        build_test_bsa_with_version_and_flags(folders, 105, 0x03)
619    }
620
621    fn build_test_bsa_with_flags(
622        folders: &[(&str, &[(&str, &[u8], bool)])],
623        archive_flags: u32,
624    ) -> Vec<u8> {
625        build_test_bsa_with_version_and_flags(folders, 105, archive_flags)
626    }
627
628    fn build_test_bsa_with_version_and_flags(
629        folders: &[(&str, &[(&str, &[u8], bool)])],
630        version: u32,
631        archive_flags: u32,
632    ) -> Vec<u8> {
633        let mut buf = Vec::new();
634        let folder_count = folders.len() as u32;
635        let file_count: u32 = folders.iter().map(|(_, files)| files.len() as u32).sum();
636        let total_folder_name_len: u32 =
637            folders.iter().map(|(name, _)| name.len() as u32 + 2).sum();
638        let total_file_name_len: u32 = folders
639            .iter()
640            .flat_map(|(_, files)| files.iter())
641            .map(|(name, _, _)| name.len() as u32 + 1)
642            .sum();
643
644        buf.extend_from_slice(BSA_MAGIC);
645        buf.extend_from_slice(&version.to_le_bytes());
646        buf.extend_from_slice(&36u32.to_le_bytes());
647        buf.extend_from_slice(&archive_flags.to_le_bytes());
648        buf.extend_from_slice(&folder_count.to_le_bytes());
649        buf.extend_from_slice(&file_count.to_le_bytes());
650        buf.extend_from_slice(&total_folder_name_len.to_le_bytes());
651        buf.extend_from_slice(&total_file_name_len.to_le_bytes());
652        buf.extend_from_slice(&0u32.to_le_bytes());
653
654        for (_, files) in folders {
655            buf.extend_from_slice(&0u64.to_le_bytes());
656            buf.extend_from_slice(&(files.len() as u32).to_le_bytes());
657            if version == 105 {
658                buf.extend_from_slice(&0u32.to_le_bytes());
659                buf.extend_from_slice(&0u64.to_le_bytes());
660            } else {
661                buf.extend_from_slice(&0u32.to_le_bytes());
662            }
663        }
664
665        let mut file_record_positions = Vec::new();
666        let mut payloads = Vec::new();
667        for (folder_name, files) in folders {
668            buf.push((folder_name.len() + 1) as u8);
669            buf.extend_from_slice(folder_name.as_bytes());
670            buf.push(0);
671
672            for (file_name, data, compressed) in *files {
673                let mut payload = if *compressed {
674                    let packed = if version >= 105 {
675                        lz4_frame(data)
676                    } else {
677                        zlib(data)
678                    };
679                    let mut payload = Vec::new();
680                    payload.extend_from_slice(&(data.len() as u32).to_le_bytes());
681                    payload.extend_from_slice(&packed);
682                    payload
683                } else {
684                    data.to_vec()
685                };
686                if archive_flags & BSA_EMBED_FILE_NAMES != 0 {
687                    let mut embedded = Vec::new();
688                    embedded.push(file_name.len() as u8);
689                    embedded.extend_from_slice(file_name.as_bytes());
690                    embedded.extend_from_slice(&payload);
691                    payload = embedded;
692                }
693                let mut size_flags = payload.len() as u32;
694                if *compressed {
695                    size_flags |= BSA_SIZE_COMPRESS_TOGGLE;
696                }
697
698                buf.extend_from_slice(&0u64.to_le_bytes());
699                buf.extend_from_slice(&size_flags.to_le_bytes());
700                file_record_positions.push(buf.len());
701                buf.extend_from_slice(&0u32.to_le_bytes());
702                payloads.push(payload);
703            }
704        }
705
706        for (_, files) in folders {
707            for (name, _, _) in *files {
708                buf.extend_from_slice(name.as_bytes());
709                buf.push(0);
710            }
711        }
712
713        for (offset_pos, payload) in file_record_positions.into_iter().zip(payloads) {
714            let offset = buf.len() as u32;
715            buf[offset_pos..offset_pos + 4].copy_from_slice(&offset.to_le_bytes());
716            buf.extend_from_slice(&payload);
717        }
718
719        buf
720    }
721
722    fn build_test_bsa_v104(folders: &[(&str, &[(&str, &[u8], bool)])]) -> Vec<u8> {
723        build_test_bsa_with_version_and_flags(folders, 104, 0x03)
724    }
725
726    fn build_test_ba2(files: &[(&str, &[u8], bool)]) -> Vec<u8> {
727        let mut buf = Vec::new();
728        let file_count = files.len() as u32;
729        let header_size = 24usize;
730        let records_size = file_count as usize * 36;
731        let name_table_size: usize = files.iter().map(|(name, _, _)| 2 + name.len()).sum();
732        let data_start = header_size + records_size + name_table_size;
733
734        buf.extend_from_slice(BA2_MAGIC);
735        buf.extend_from_slice(&1u32.to_le_bytes());
736        buf.extend_from_slice(b"GNRL");
737        buf.extend_from_slice(&file_count.to_le_bytes());
738        buf.extend_from_slice(&((header_size + records_size) as u64).to_le_bytes());
739
740        let mut payloads = Vec::new();
741        let mut running_offset = data_start as u64;
742        for (_, data, compressed) in files {
743            let payload = if *compressed {
744                zlib(data)
745            } else {
746                data.to_vec()
747            };
748            buf.extend_from_slice(&0u32.to_le_bytes());
749            buf.extend_from_slice(&0u32.to_le_bytes());
750            buf.extend_from_slice(&0u32.to_le_bytes());
751            buf.extend_from_slice(&0u32.to_le_bytes());
752            buf.extend_from_slice(&running_offset.to_le_bytes());
753            buf.extend_from_slice(
754                &(if *compressed { payload.len() as u32 } else { 0 }).to_le_bytes(),
755            );
756            buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
757            buf.extend_from_slice(&0xBAADF00Du32.to_le_bytes());
758            running_offset += payload.len() as u64;
759            payloads.push(payload);
760        }
761
762        for (name, _, _) in files {
763            buf.extend_from_slice(&(name.len() as u16).to_le_bytes());
764            buf.extend_from_slice(name.as_bytes());
765        }
766
767        for payload in payloads {
768            buf.extend_from_slice(&payload);
769        }
770
771        buf
772    }
773
774    #[test]
775    fn parse_synthetic_bsa_v105() {
776        let data = build_test_bsa(&[
777            (
778                "textures",
779                &[
780                    ("sky.dds", b"sky".as_slice(), false),
781                    ("ground.dds", b"ground", false),
782                ],
783            ),
784            ("meshes", &[("tree.nif", b"tree", false)]),
785        ]);
786        let mut cursor = Cursor::new(&data);
787        cursor.set_position(4);
788        let entries = read_bsa(&mut cursor).unwrap();
789        assert_eq!(entries.len(), 3);
790        assert_eq!(entries[0].path, "textures/sky.dds");
791        assert_eq!(entries[1].path, "textures/ground.dds");
792        assert_eq!(entries[2].path, "meshes/tree.nif");
793    }
794
795    #[test]
796    fn extract_synthetic_bsa_v105_lz4_compressed() {
797        let data = build_test_bsa(&[(
798            "textures",
799            &[
800                ("sky.dds", b"sky bytes".as_slice(), false),
801                ("cloud.dds", b"cloud bytes", true),
802            ],
803        )]);
804        let tmp = write_temp(&data);
805        let index = ArchiveIndex::read(tmp.path()).unwrap();
806        assert_eq!(index.files[0].size, b"sky bytes".len() as u64);
807        assert_eq!(index.files[1].size, b"cloud bytes".len() as u64);
808        assert_eq!(
809            index.extract_file("textures/sky.dds").unwrap(),
810            b"sky bytes"
811        );
812        assert_eq!(
813            index.extract_file("Textures\\Cloud.dds").unwrap(),
814            b"cloud bytes"
815        );
816    }
817
818    #[test]
819    fn extract_synthetic_bsa_v104_zlib_compressed() {
820        let data = build_test_bsa_v104(&[(
821            "textures",
822            &[("cloud.dds", b"cloud bytes".as_slice(), true)],
823        )]);
824        let tmp = write_temp(&data);
825        let index = ArchiveIndex::read(tmp.path()).unwrap();
826        assert_eq!(index.files[0].size, b"cloud bytes".len() as u64);
827        assert_eq!(
828            index.extract_file("Textures\\Cloud.dds").unwrap(),
829            b"cloud bytes"
830        );
831    }
832
833    #[test]
834    fn extract_synthetic_bsa_with_embedded_names() {
835        let data = build_test_bsa_with_flags(
836            &[(
837                "scripts",
838                &[("quest.pex", b"script bytes".as_slice(), true)],
839            )],
840            0x03 | BSA_EMBED_FILE_NAMES,
841        );
842        let tmp = write_temp(&data);
843        let index = ArchiveIndex::read(tmp.path()).unwrap();
844        assert_eq!(index.files[0].size, b"script bytes".len() as u64);
845        assert_eq!(
846            index.extract_file("scripts/quest.pex").unwrap(),
847            b"script bytes"
848        );
849    }
850
851    #[test]
852    fn bethesda_magic_probe_distinguishes_formats() {
853        let bsa = write_temp(&build_test_bsa(&[(
854            "textures",
855            &[("sky.dds", b"sky".as_slice(), false)],
856        )]));
857        let other = write_temp(b"PK\x03\x04not bethesda");
858
859        assert!(ArchiveIndex::has_bethesda_magic(bsa.path()).unwrap());
860        assert!(!ArchiveIndex::has_bethesda_magic(other.path()).unwrap());
861    }
862
863    #[test]
864    fn parse_and_extract_synthetic_ba2_gnrl() {
865        let data = build_test_ba2(&[
866            ("textures\\sky.dds", b"sky bytes".as_slice(), false),
867            ("meshes\\tree.nif", b"tree bytes", true),
868        ]);
869        let tmp = write_temp(&data);
870        let index = ArchiveIndex::read(tmp.path()).unwrap();
871        assert_eq!(index.files.len(), 2);
872        assert_eq!(index.files[0].path, "textures/sky.dds");
873        assert_eq!(
874            index.extract_file("textures/sky.dds").unwrap(),
875            b"sky bytes"
876        );
877        assert_eq!(
878            index.extract_file("meshes/tree.nif").unwrap(),
879            b"tree bytes"
880        );
881    }
882
883    #[test]
884    fn normalize_path_handles_backslashes_and_case() {
885        assert_eq!(normalize_path("Textures\\Sky.DDS"), "textures/sky.dds");
886        assert_eq!(normalize_path("/textures/sky.dds"), "textures/sky.dds");
887        assert_eq!(normalize_path("./meshes/tree.nif"), "meshes/tree.nif");
888    }
889
890    #[test]
891    fn bad_magic_returns_error() {
892        let tmp = write_temp(b"NOPE____");
893        let result = ArchiveIndex::read(tmp.path());
894        assert!(result.is_err());
895        assert!(format!("{}", result.unwrap_err()).contains("unrecognised archive format"));
896    }
897}