diff --git a/README.md b/README.md index 670c62e..b5b77ef 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/casc-lib/src/blte/compression.rs b/crates/casc-lib/src/blte/compression.rs index cede77e..aca18c3 100644 --- a/crates/casc-lib/src/blte/compression.rs +++ b/crates/casc-lib/src/blte/compression.rs @@ -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}; @@ -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 @@ -55,6 +54,11 @@ fn decode_encrypted(data: &[u8], keystore: Option<&TactKeyStore>) -> Result) -> Result> { + // 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> { Ok(data.to_vec()) } @@ -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]; diff --git a/crates/casc-lib/src/blte/mod.rs b/crates/casc-lib/src/blte/mod.rs index a65361c..aae1afb 100644 --- a/crates/casc-lib/src/blte/mod.rs +++ b/crates/casc-lib/src/blte/mod.rs @@ -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; diff --git a/crates/casc-lib/src/root/parser.rs b/crates/casc-lib/src/root/parser.rs index fce68d0..b96e3e3 100644 --- a/crates/casc-lib/src/root/parser.rs +++ b/crates/casc-lib/src/root/parser.rs @@ -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, @@ -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 @@ -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(&[]);