Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ A pure Rust CLI tool and library for reading World of Warcraft CASC (Content Add

## Features

- **BLTE decoding** - supports N (plain), Z (zlib), and 4 (LZ4) compression modes
- **BLTE decoding** - supports N (plain), Z (zlib), 4 (LZ4), and F (recursive frame) compression modes
- **TACT encryption** - Salsa20 and ARC4 decryption with configurable key stores
- **LZ4 sub-block decompression** - handles chunked BLTE frames with LZ4 compression
- **Parallel extraction** - multi-threaded file extraction powered by rayon
Expand Down
53 changes: 49 additions & 4 deletions crates/casc-lib/src/blte/compression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
//! - `4` (0x34) - LZ4 block compression with sub-block framing.
//! - `E` (0x45) - encrypted block; after decryption the inner payload is
//! recursively decoded (its first byte is the inner compression mode).
//! - `F` (0x46) - recursive BLTE (not currently supported).
//! - `F` (0x46) - recursive BLTE frame; the payload is a complete nested
//! BLTE stream that is decoded recursively.

use super::encryption::{TactKeyStore, decrypt_block as decrypt_encrypted_block};
use crate::error::{CascError, Result};
Expand All @@ -27,9 +28,7 @@ pub fn decode_block_with_keys(block: &[u8], keystore: Option<&TactKeyStore>) ->
b'Z' => decode_zlib(&block[1..]),
b'4' => decode_lz4(&block[1..]),
b'E' => decode_encrypted(&block[1..], keystore),
b'F' => Err(CascError::InvalidFormat(
"recursive BLTE (mode F) not supported".into(),
)),
b'F' => decode_frame(&block[1..], keystore),
mode => Err(CascError::InvalidFormat(format!(
"unknown BLTE mode: 0x{:02X}",
mode
Expand All @@ -55,6 +54,11 @@ fn decode_encrypted(data: &[u8], keystore: Option<&TactKeyStore>) -> Result<Vec<
decode_block_with_keys(&decrypted, Some(keystore))
}

fn decode_frame(data: &[u8], keystore: Option<&TactKeyStore>) -> Result<Vec<u8>> {
// Mode F wraps a complete nested BLTE stream (starting with "BLTE" magic).
super::decoder::decode_blte_with_keys(data, keystore)
}

fn decode_raw(data: &[u8]) -> Result<Vec<u8>> {
Ok(data.to_vec())
}
Expand Down Expand Up @@ -237,6 +241,47 @@ mod tests {
assert_eq!(result, b"hello");
}

#[test]
fn mode_f_recursive_blte() {
// Mode F wraps a complete nested BLTE stream
let mut inner = Vec::new();
inner.extend_from_slice(b"BLTE");
inner.extend_from_slice(&0u32.to_be_bytes()); // single-block
inner.push(b'N');
inner.extend_from_slice(b"nested content");

let mut block = vec![b'F'];
block.extend_from_slice(&inner);

let result = decode_block(&block).unwrap();
assert_eq!(result, b"nested content");
}

#[test]
fn mode_f_nested_zlib() {
let original = b"nested zlib payload";
let compressed = zlib_compress(original);

let mut inner = Vec::new();
inner.extend_from_slice(b"BLTE");
inner.extend_from_slice(&0u32.to_be_bytes());
inner.push(b'Z');
inner.extend_from_slice(&compressed);

let mut block = vec![b'F'];
block.extend_from_slice(&inner);

let result = decode_block(&block).unwrap();
assert_eq!(result, original);
}

#[test]
fn mode_f_invalid_inner_errors() {
// Payload without a valid nested BLTE magic must fail
let block = vec![b'F', b'X', b'X', b'X', b'X', 0, 0, 0, 0];
assert!(decode_block(&block).is_err());
}

#[test]
fn mode_unknown_returns_error() {
let block = vec![b'X', 0x00];
Expand Down
3 changes: 2 additions & 1 deletion crates/casc-lib/src/blte/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
//! encrypted game data. A BLTE blob starts with a 4-byte `"BLTE"` magic, a
//! header-size field, and then either a single data block (when header size is 0)
//! or a chunk table describing multiple blocks. Each block carries a one-byte
//! mode prefix: `N` (raw), `Z` (zlib), `4` (LZ4), or `E` (encrypted).
//! mode prefix: `N` (raw), `Z` (zlib), `4` (LZ4), `E` (encrypted), or
//! `F` (recursive BLTE frame).

/// Block-level compression and mode dispatch (N, Z, 4, E, F).
pub mod compression;
Expand Down
61 changes: 55 additions & 6 deletions crates/casc-lib/src/root/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,15 @@ fn detect_format(data: &[u8]) -> Result<(RootFormat, usize)> {
let field_at_4 = read_le_u32(&data[4..8]);

// For pre-10.1.7 MFST: header is magic(4) + total_count(4) + named_count(4) = 12 bytes.
// For 10.1.7+: offset 4 = header_size (24), offset 8 = version (1 or 2).
// Distinguish: if field_at_4 looks like a reasonable header_size (e.g. 24),
// it's the 10.1.7+ format. If it's a huge number, it's the old 12-byte header
// where field_at_4 is total_file_count.
if field_at_4 == 24 && data.len() >= 24 {
// For 10.1.7+: offset 4 = header_size, offset 8 = version (1 or 2).
// Distinguish: if field_at_4 looks like a reasonable header_size (small value,
// at least 12 bytes), it's the 10.1.7+ format. If it's a huge number, it's the
// old 12-byte header where field_at_4 is total_file_count. Newer clients may
// grow the header, so honor the declared header_size rather than requiring
// exactly 24 bytes - blocks always start at header_size.
if (12..=1024).contains(&field_at_4) && data.len() >= field_at_4 as usize {
// 10.1.7+ format with explicit header_size and version
let header_size = field_at_4 as usize;
let version = read_le_u32(&data[8..12]);
let format = match version {
1 => RootFormat::MfstV1,
Expand All @@ -163,7 +166,7 @@ fn detect_format(data: &[u8]) -> Result<(RootFormat, usize)> {
return Err(CascError::UnsupportedVersion(version));
}
};
Ok((format, 24))
Ok((format, header_size))
} else {
// Pre-10.1.7 MFST: 12-byte header (magic + total_count + named_count)
// Block format is v1
Expand Down Expand Up @@ -555,6 +558,52 @@ mod tests {
assert_eq!(entry.name_hash, None);
}

#[test]
fn mfst_extended_header_size() {
// Newer clients may grow the MFST header - the declared header_size
// must be honored so blocks are read from the right offset.
let mut data = Vec::new();
// MFST header (32 bytes, larger than the usual 24)
data.extend_from_slice(&MFST_MAGIC_BE.to_le_bytes());
data.extend_from_slice(&32u32.to_le_bytes()); // header_size = 32
data.extend_from_slice(&2u32.to_le_bytes()); // version = 2
data.extend_from_slice(&1u32.to_le_bytes()); // total_file_count
data.extend_from_slice(&0u32.to_le_bytes()); // named_file_count
data.extend_from_slice(&[0u8; 12]); // extra header fields / padding
assert_eq!(data.len(), 32);

// Block header v2
data.extend_from_slice(&1u32.to_le_bytes()); // num_records = 1
data.extend_from_slice(&0x2u32.to_le_bytes()); // locale_flags = enUS
data.extend_from_slice(&0x8u32.to_le_bytes()); // unk1
data.extend_from_slice(&0x10000000u32.to_le_bytes()); // unk2 = NoNameHash
data.push(0); // unk3

data.extend_from_slice(&77i32.to_le_bytes()); // delta (fdid = 77)
data.extend_from_slice(&[0xCD; 16]); // ckey

let root = RootFile::parse(&data).unwrap();
assert_eq!(root.format(), RootFormat::MfstV2);
assert_eq!(root.len(), 1);
let entry = root.find_by_fdid(77, LocaleFlags::EN_US).unwrap();
assert_eq!(entry.ckey, [0xCD; 16]);
}

#[test]
fn mfst_unsupported_version_errors() {
let mut data = Vec::new();
data.extend_from_slice(&MFST_MAGIC_BE.to_le_bytes());
data.extend_from_slice(&24u32.to_le_bytes()); // header_size
data.extend_from_slice(&99u32.to_le_bytes()); // version = 99 (unknown)
data.extend_from_slice(&[0u8; 12]);

let err = match RootFile::parse(&data) {
Err(e) => e,
Ok(_) => panic!("expected UnsupportedVersion error"),
};
assert!(matches!(err, CascError::UnsupportedVersion(99)));
}

#[test]
fn parse_error_on_empty_data() {
let result = RootFile::parse(&[]);
Expand Down
Loading