1use 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#[derive(Debug, Clone)]
22pub struct ArchiveFileEntry {
23 pub path: String,
25 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#[derive(Debug, Clone)]
37pub struct ArchiveIndex {
38 pub archive_path: PathBuf,
40 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 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 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 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 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#[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}