Skip to main content

casc_lib/root/
parser.rs

1//! Root file binary parser.
2//!
3//! Supports three root file formats used across WoW versions:
4//!
5//! - **Legacy** (pre-8.2) - no header, blocks start at offset 0.
6//! - **MFST V1** (8.2 - 11.0.x) - `MFST` magic header, 12-byte block headers.
7//! - **MFST V2** (11.1.0+) - `MFST` magic header, 17-byte block headers with
8//!   restructured content flags.
9//!
10//! Each root file is organized as a series of blocks, where each block shares
11//! a common set of locale and content flags. Within a block, FileDataIDs are
12//! stored as delta-encoded integers followed by parallel arrays of CKeys and
13//! (optionally) name hashes.
14
15use std::collections::HashMap;
16
17use crate::error::{CascError, Result};
18use crate::util::io::{read_le_i32, read_le_u32, read_le_u64};
19
20use super::flags::{ContentFlags, LocaleFlags};
21
22/// Magic number for MFST header.
23///
24/// Real WoW root files store the bytes `[54 53 46 4D]` ("TSFM"), which is
25/// the string "MFST" written as a big-endian u32 (`0x5453464D`).  When read
26/// back with `read_le_u32` this yields `0x4D465354`.  We check both values
27/// so that hand-built test data (which writes `MFST_MAGIC_BE.to_le_bytes()`)
28/// and real game files are both recognized.
29const MFST_MAGIC_BE: u32 = 0x5453464D; // read_le on bytes "TSFM" -> 0x4D465354, but this is the BE interpretation
30const MFST_MAGIC_LE: u32 = 0x4D465354; // what read_le_u32 actually returns for real files
31
32/// A single root file entry mapping a CKey to flags/locale/name hash.
33#[derive(Debug, Clone)]
34pub struct RootEntry {
35    /// Content key identifying the file data in the encoding table.
36    pub ckey: [u8; 16],
37    /// Content flags (platform, encryption, compression hints).
38    pub content_flags: ContentFlags,
39    /// Locale flags indicating which client locales this entry applies to.
40    pub locale_flags: LocaleFlags,
41    /// Jenkins96 name hash of the original file path, or `None` when the
42    /// `NoNameHash` content flag is set.
43    pub name_hash: Option<u64>,
44}
45
46/// Detected root file format.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum RootFormat {
49    /// Pre-8.2, no MFST header - blocks start immediately.
50    Legacy,
51    /// 8.2+, MFST header with block format version 1.
52    MfstV1,
53    /// 11.1.0+, MFST header with block format version 2.
54    MfstV2,
55}
56
57/// Parsed root file with FileDataID -> CKey lookup.
58pub struct RootFile {
59    format: RootFormat,
60    /// FileDataID -> `Vec<RootEntry>` (may have multiple locale variants).
61    entries: HashMap<u32, Vec<RootEntry>>,
62    total_entries: usize,
63}
64
65impl RootFile {
66    /// Parse a root file from raw bytes.
67    pub fn parse(data: &[u8]) -> Result<Self> {
68        let (format, block_start) = detect_format(data)?;
69
70        let mut entries: HashMap<u32, Vec<RootEntry>> = HashMap::new();
71        let mut total_entries: usize = 0;
72        let mut pos = block_start;
73
74        while pos < data.len() {
75            let (block_entries, new_pos) = parse_block(data, pos, format)?;
76            total_entries += block_entries.len();
77            for (fdid, entry) in block_entries {
78                entries.entry(fdid).or_default().push(entry);
79            }
80            pos = new_pos;
81        }
82
83        Ok(Self {
84            format,
85            entries,
86            total_entries,
87        })
88    }
89
90    /// Find the first entry for a FileDataID that matches the given locale filter.
91    pub fn find_by_fdid(&self, fdid: u32, locale: LocaleFlags) -> Option<&RootEntry> {
92        self.entries
93            .get(&fdid)?
94            .iter()
95            .find(|e| e.locale_flags.matches(locale))
96    }
97
98    /// Iterate all (FileDataID, entry) pairs.
99    pub fn iter_all(&self) -> impl Iterator<Item = (u32, &RootEntry)> {
100        self.entries
101            .iter()
102            .flat_map(|(fdid, entries)| entries.iter().map(move |entry| (*fdid, entry)))
103    }
104
105    /// The detected format of this root file.
106    pub fn format(&self) -> RootFormat {
107        self.format
108    }
109
110    /// Total number of entries across all blocks.
111    pub fn len(&self) -> usize {
112        self.total_entries
113    }
114
115    /// Whether the root file contains no entries.
116    pub fn is_empty(&self) -> bool {
117        self.total_entries == 0
118    }
119
120    /// Number of unique FileDataIDs.
121    pub fn fdid_count(&self) -> usize {
122        self.entries.len()
123    }
124}
125
126/// Detect the root file format and return the byte offset where blocks begin.
127fn detect_format(data: &[u8]) -> Result<(RootFormat, usize)> {
128    if data.len() < 4 {
129        // Too short for MFST header - treat as legacy if it has any data, else empty
130        if data.is_empty() {
131            return Err(CascError::InvalidFormat("root file is empty".to_string()));
132        }
133        return Ok((RootFormat::Legacy, 0));
134    }
135
136    let magic = read_le_u32(&data[0..4]);
137    if magic != MFST_MAGIC_LE && magic != MFST_MAGIC_BE {
138        // No MFST header - legacy format, blocks start at offset 0
139        return Ok((RootFormat::Legacy, 0));
140    }
141
142    // Has MFST magic. Determine header size.
143    if data.len() < 12 {
144        return Err(CascError::InvalidFormat(
145            "MFST header too short".to_string(),
146        ));
147    }
148
149    let field_at_4 = read_le_u32(&data[4..8]);
150
151    // For pre-10.1.7 MFST: header is magic(4) + total_count(4) + named_count(4) = 12 bytes.
152    // For 10.1.7+: offset 4 = header_size, offset 8 = version (1 or 2).
153    // Distinguish: if field_at_4 looks like a reasonable header_size (small value,
154    // at least 12 bytes), it's the 10.1.7+ format. If it's a huge number, it's the
155    // old 12-byte header where field_at_4 is total_file_count. Newer clients may
156    // grow the header, so honor the declared header_size rather than requiring
157    // exactly 24 bytes - blocks always start at header_size.
158    if (12..=1024).contains(&field_at_4) && data.len() >= field_at_4 as usize {
159        // 10.1.7+ format with explicit header_size and version
160        let header_size = field_at_4 as usize;
161        let version = read_le_u32(&data[8..12]);
162        let format = match version {
163            1 => RootFormat::MfstV1,
164            2 => RootFormat::MfstV2,
165            _ => {
166                return Err(CascError::UnsupportedVersion(version));
167            }
168        };
169        Ok((format, header_size))
170    } else {
171        // Pre-10.1.7 MFST: 12-byte header (magic + total_count + named_count)
172        // Block format is v1
173        Ok((RootFormat::MfstV1, 12))
174    }
175}
176
177/// Parse a single block from the root file data at the given position.
178/// Returns the list of (FileDataID, RootEntry) pairs and the new position after the block.
179fn parse_block(
180    data: &[u8],
181    pos: usize,
182    format: RootFormat,
183) -> Result<(Vec<(u32, RootEntry)>, usize)> {
184    let (num_records, content_flags, locale_flags, mut pos) =
185        parse_block_header(data, pos, format)?;
186
187    if num_records == 0 {
188        return Ok((Vec::new(), pos));
189    }
190
191    let num = num_records as usize;
192
193    // Read FileDataID deltas (i32 LE each)
194    let deltas_size = num * 4;
195    if pos + deltas_size > data.len() {
196        return Err(CascError::InvalidFormat(
197            "root block: not enough data for FileDataID deltas".to_string(),
198        ));
199    }
200
201    let mut fdids = Vec::with_capacity(num);
202    let mut current_fdid: i64 = 0;
203    for i in 0..num {
204        let delta = read_le_i32(&data[pos + i * 4..]) as i64;
205        if i == 0 {
206            // First delta is the absolute starting FileDataID
207            current_fdid = delta;
208        } else {
209            current_fdid = current_fdid + 1 + delta;
210        }
211        fdids.push(current_fdid as u32);
212    }
213    pos += deltas_size;
214
215    // Read CKeys (16 bytes each)
216    let ckeys_size = num * 16;
217    if pos + ckeys_size > data.len() {
218        return Err(CascError::InvalidFormat(
219            "root block: not enough data for content keys".to_string(),
220        ));
221    }
222
223    let mut ckeys = Vec::with_capacity(num);
224    for i in 0..num {
225        let mut ckey = [0u8; 16];
226        ckey.copy_from_slice(&data[pos + i * 16..pos + i * 16 + 16]);
227        ckeys.push(ckey);
228    }
229    pos += ckeys_size;
230
231    // Read name hashes (u64 LE each) - only if NoNameHash flag is NOT set
232    let has_name_hashes = !content_flags.has_no_name_hash();
233    let mut name_hashes: Vec<Option<u64>> = Vec::with_capacity(num);
234
235    if has_name_hashes {
236        let hashes_size = num * 8;
237        if pos + hashes_size > data.len() {
238            return Err(CascError::InvalidFormat(
239                "root block: not enough data for name hashes".to_string(),
240            ));
241        }
242        for i in 0..num {
243            name_hashes.push(Some(read_le_u64(&data[pos + i * 8..])));
244        }
245        pos += hashes_size;
246    } else {
247        name_hashes.resize(num, None);
248    }
249
250    // Assemble entries
251    let mut result = Vec::with_capacity(num);
252    for i in 0..num {
253        result.push((
254            fdids[i],
255            RootEntry {
256                ckey: ckeys[i],
257                content_flags,
258                locale_flags,
259                name_hash: name_hashes[i],
260            },
261        ));
262    }
263
264    Ok((result, pos))
265}
266
267/// Parse a block header and return (num_records, content_flags, locale_flags, new_pos).
268fn parse_block_header(
269    data: &[u8],
270    pos: usize,
271    format: RootFormat,
272) -> Result<(u32, ContentFlags, LocaleFlags, usize)> {
273    match format {
274        RootFormat::Legacy | RootFormat::MfstV1 => {
275            // Block header v1: num_records(4) + content_flags(4) + locale_flags(4) = 12 bytes
276            if pos + 12 > data.len() {
277                return Err(CascError::InvalidFormat(
278                    "root block header v1: not enough data".to_string(),
279                ));
280            }
281            let num_records = read_le_u32(&data[pos..]);
282            let content_flags = ContentFlags(read_le_u32(&data[pos + 4..]));
283            let locale_flags = LocaleFlags(read_le_u32(&data[pos + 8..]));
284            Ok((num_records, content_flags, locale_flags, pos + 12))
285        }
286        RootFormat::MfstV2 => {
287            // Block header v2: num_records(4) + locale_flags(4) + unk1(4) + unk2(4) + unk3(1) = 17 bytes
288            if pos + 17 > data.len() {
289                return Err(CascError::InvalidFormat(
290                    "root block header v2: not enough data".to_string(),
291                ));
292            }
293            let num_records = read_le_u32(&data[pos..]);
294            let locale_flags = LocaleFlags(read_le_u32(&data[pos + 4..]));
295            let unk1 = read_le_u32(&data[pos + 8..]);
296            let unk2 = read_le_u32(&data[pos + 12..]);
297            let unk3 = data[pos + 16];
298            // Convert to old-style content_flags
299            let content_flags = ContentFlags(unk1 | unk2 | ((unk3 as u32) << 17));
300            Ok((num_records, content_flags, locale_flags, pos + 17))
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::root::flags::{ContentFlags, LocaleFlags};
309
310    type RootBlockEntry = (i32, [u8; 16], Option<u64>);
311
312    /// Build a v1 MFST root file with given blocks.
313    /// Each block: (content_flags, locale_flags, entries: Vec<(fdid_delta, ckey, name_hash?)>)
314    fn build_root_v1(blocks: &[(u32, u32, Vec<RootBlockEntry>)]) -> Vec<u8> {
315        let total_count: u32 = blocks.iter().map(|(_, _, e)| e.len() as u32).sum();
316        let named_count: u32 = blocks
317            .iter()
318            .filter(|(cf, _, _)| (cf & 0x10000000) == 0)
319            .map(|(_, _, e)| e.len() as u32)
320            .sum();
321
322        let mut data = Vec::new();
323
324        // MFST header (24 bytes for 10.1.7+ format)
325        data.extend_from_slice(&MFST_MAGIC_BE.to_le_bytes()); // magic "MFST"
326        data.extend_from_slice(&24u32.to_le_bytes()); // header_size
327        data.extend_from_slice(&1u32.to_le_bytes()); // version = 1
328        data.extend_from_slice(&total_count.to_le_bytes()); // total_file_count
329        data.extend_from_slice(&named_count.to_le_bytes()); // named_file_count
330        data.extend_from_slice(&0u32.to_le_bytes()); // padding
331        assert_eq!(data.len(), 24);
332
333        // Blocks
334        for (content_flags, locale_flags, entries) in blocks {
335            let num_records = entries.len() as u32;
336            // Block header v1: num_records + content_flags + locale_flags
337            data.extend_from_slice(&num_records.to_le_bytes());
338            data.extend_from_slice(&content_flags.to_le_bytes());
339            data.extend_from_slice(&locale_flags.to_le_bytes());
340
341            // FileDataID deltas
342            for (delta, _, _) in entries {
343                data.extend_from_slice(&delta.to_le_bytes());
344            }
345
346            // CKeys
347            for (_, ckey, _) in entries {
348                data.extend_from_slice(ckey);
349            }
350
351            // Name hashes (only if NoNameHash not set)
352            if (content_flags & 0x10000000) == 0 {
353                for (_, _, name_hash) in entries {
354                    let hash = name_hash.unwrap_or(0);
355                    data.extend_from_slice(&hash.to_le_bytes());
356                }
357            }
358        }
359
360        data
361    }
362
363    #[test]
364    fn detect_mfst_format() {
365        let data = build_root_v1(&[]);
366        let root = RootFile::parse(&data).unwrap();
367        assert_eq!(root.format(), RootFormat::MfstV1);
368    }
369
370    #[test]
371    fn parse_single_block_single_entry() {
372        let ckey = [0xAA; 16];
373        let blocks = vec![(0x8u32, 0x2u32, vec![(100i32, ckey, Some(0xDEADBEEF_u64))])]; // Windows, enUS
374        let data = build_root_v1(&blocks);
375        let root = RootFile::parse(&data).unwrap();
376
377        assert_eq!(root.len(), 1);
378        let entry = root.find_by_fdid(100, LocaleFlags::EN_US).unwrap();
379        assert_eq!(entry.ckey, ckey);
380        assert_eq!(entry.name_hash, Some(0xDEADBEEF));
381    }
382
383    #[test]
384    fn parse_fdid_deltas_sequential() {
385        let blocks = vec![(
386            0x10000008u32,
387            0x2u32,
388            vec![
389                (100i32, [0x01; 16], None), // fdid = 100
390                (0i32, [0x02; 16], None),   // fdid = 101 (100 + 1 + 0)
391                (0i32, [0x03; 16], None),   // fdid = 102
392                (2i32, [0x04; 16], None),   // fdid = 105 (102 + 1 + 2)
393            ],
394        )];
395        let data = build_root_v1(&blocks);
396        let root = RootFile::parse(&data).unwrap();
397
398        assert_eq!(root.len(), 4);
399        assert!(root.find_by_fdid(100, LocaleFlags::ALL).is_some());
400        assert!(root.find_by_fdid(101, LocaleFlags::ALL).is_some());
401        assert!(root.find_by_fdid(102, LocaleFlags::ALL).is_some());
402        assert!(root.find_by_fdid(103, LocaleFlags::ALL).is_none()); // gap
403        assert!(root.find_by_fdid(104, LocaleFlags::ALL).is_none()); // gap
404        assert!(root.find_by_fdid(105, LocaleFlags::ALL).is_some());
405    }
406
407    #[test]
408    fn parse_block_with_name_hashes() {
409        let blocks = vec![(
410            0x8u32,
411            0x2u32,
412            vec![(50i32, [0xBB; 16], Some(0x1234567890ABCDEF_u64))],
413        )]; // No NoNameHash flag = has name hashes
414        let data = build_root_v1(&blocks);
415        let root = RootFile::parse(&data).unwrap();
416
417        let entry = root.find_by_fdid(50, LocaleFlags::ALL).unwrap();
418        assert_eq!(entry.name_hash, Some(0x1234567890ABCDEF));
419    }
420
421    #[test]
422    fn parse_block_without_name_hashes() {
423        let blocks = vec![(0x10000008u32, 0x2u32, vec![(50i32, [0xCC; 16], None)])]; // NoNameHash flag set
424        let data = build_root_v1(&blocks);
425        let root = RootFile::parse(&data).unwrap();
426
427        let entry = root.find_by_fdid(50, LocaleFlags::ALL).unwrap();
428        assert_eq!(entry.name_hash, None);
429    }
430
431    #[test]
432    fn parse_multiple_blocks_different_locales() {
433        let blocks = vec![
434            (0x8u32, 0x2u32, vec![(100i32, [0x01; 16], Some(0))]), // enUS
435            (0x8u32, 0x20u32, vec![(100i32, [0x02; 16], Some(0))]), // deDE, same fdid!
436        ];
437        let data = build_root_v1(&blocks);
438        let root = RootFile::parse(&data).unwrap();
439
440        // Same fdid, different locales
441        let en = root.find_by_fdid(100, LocaleFlags::EN_US).unwrap();
442        assert_eq!(en.ckey, [0x01; 16]);
443
444        let de = root.find_by_fdid(100, LocaleFlags::DE_DE).unwrap();
445        assert_eq!(de.ckey, [0x02; 16]);
446    }
447
448    #[test]
449    fn parse_locale_filter() {
450        let blocks = vec![(0x8u32, 0x20u32, vec![(200i32, [0xFF; 16], Some(0))])]; // deDE only
451        let data = build_root_v1(&blocks);
452        let root = RootFile::parse(&data).unwrap();
453
454        assert!(root.find_by_fdid(200, LocaleFlags::EN_US).is_none()); // not enUS
455        assert!(root.find_by_fdid(200, LocaleFlags::DE_DE).is_some()); // deDE
456        assert!(root.find_by_fdid(200, LocaleFlags::ALL).is_some()); // ALL matches
457    }
458
459    #[test]
460    fn iter_all_entries() {
461        let blocks = vec![(
462            0x10000008u32,
463            0x2u32,
464            vec![(10i32, [0x01; 16], None), (0i32, [0x02; 16], None)],
465        )];
466        let data = build_root_v1(&blocks);
467        let root = RootFile::parse(&data).unwrap();
468
469        let all: Vec<_> = root.iter_all().collect();
470        assert_eq!(all.len(), 2);
471    }
472
473    #[test]
474    fn parse_empty_root() {
475        let data = build_root_v1(&[]);
476        let root = RootFile::parse(&data).unwrap();
477        assert!(root.is_empty());
478        assert_eq!(root.fdid_count(), 0);
479    }
480
481    #[test]
482    fn detect_legacy_format() {
483        // Data that doesn't start with MFST magic - should be Legacy.
484        // Build a minimal legacy root with one block (block header starts at offset 0).
485        let mut data = Vec::new();
486        // Block header v1: num_records=1, content_flags=0x10000008, locale_flags=0x2
487        data.extend_from_slice(&1u32.to_le_bytes());
488        data.extend_from_slice(&0x10000008u32.to_le_bytes());
489        data.extend_from_slice(&0x2u32.to_le_bytes());
490        // Delta: fdid = 42
491        data.extend_from_slice(&42i32.to_le_bytes());
492        // CKey
493        data.extend_from_slice(&[0xDD; 16]);
494        // No name hashes (NoNameHash set)
495
496        let root = RootFile::parse(&data).unwrap();
497        assert_eq!(root.format(), RootFormat::Legacy);
498        assert_eq!(root.len(), 1);
499        assert!(root.find_by_fdid(42, LocaleFlags::ALL).is_some());
500    }
501
502    #[test]
503    fn detect_pre_1017_mfst() {
504        // Pre-10.1.7 MFST: 12-byte header (magic + total_count + named_count)
505        let mut data = Vec::new();
506        data.extend_from_slice(&MFST_MAGIC_BE.to_le_bytes()); // magic
507        data.extend_from_slice(&500000u32.to_le_bytes()); // total_count (large number, not 24)
508        data.extend_from_slice(&400000u32.to_le_bytes()); // named_count
509
510        // One block after header
511        data.extend_from_slice(&1u32.to_le_bytes()); // num_records
512        data.extend_from_slice(&0x10000008u32.to_le_bytes()); // content_flags
513        data.extend_from_slice(&0x2u32.to_le_bytes()); // locale_flags
514        data.extend_from_slice(&7i32.to_le_bytes()); // delta (fdid = 7)
515        data.extend_from_slice(&[0xEE; 16]); // ckey
516        // No name hashes
517
518        let root = RootFile::parse(&data).unwrap();
519        assert_eq!(root.format(), RootFormat::MfstV1);
520        assert_eq!(root.len(), 1);
521        assert!(root.find_by_fdid(7, LocaleFlags::ALL).is_some());
522    }
523
524    #[test]
525    fn mfst_v2_block_header() {
526        // Build a v2 MFST root manually
527        let mut data = Vec::new();
528        // MFST header (24 bytes)
529        data.extend_from_slice(&MFST_MAGIC_BE.to_le_bytes());
530        data.extend_from_slice(&24u32.to_le_bytes()); // header_size
531        data.extend_from_slice(&2u32.to_le_bytes()); // version = 2
532        data.extend_from_slice(&1u32.to_le_bytes()); // total_file_count
533        data.extend_from_slice(&0u32.to_le_bytes()); // named_file_count
534        data.extend_from_slice(&0u32.to_le_bytes()); // padding
535
536        // Block header v2: num_records(4) + locale_flags(4) + unk1(4) + unk2(4) + unk3(1) = 17 bytes
537        data.extend_from_slice(&1u32.to_le_bytes()); // num_records = 1
538        data.extend_from_slice(&0x2u32.to_le_bytes()); // locale_flags = enUS
539        data.extend_from_slice(&0x8u32.to_le_bytes()); // unk1 = 0x8 (LoadOnWindows)
540        data.extend_from_slice(&0x10000000u32.to_le_bytes()); // unk2 = NoNameHash
541        data.push(0); // unk3 = 0
542
543        // Delta
544        data.extend_from_slice(&99i32.to_le_bytes());
545        // CKey
546        data.extend_from_slice(&[0xAB; 16]);
547        // No name hashes (NoNameHash is set via unk2)
548
549        let root = RootFile::parse(&data).unwrap();
550        assert_eq!(root.format(), RootFormat::MfstV2);
551        assert_eq!(root.len(), 1);
552
553        let entry = root.find_by_fdid(99, LocaleFlags::EN_US).unwrap();
554        assert_eq!(entry.ckey, [0xAB; 16]);
555        // content_flags should be unk1 | unk2 | (unk3 << 17) = 0x8 | 0x10000000 | 0
556        assert!(entry.content_flags.has(ContentFlags::LOAD_ON_WINDOWS));
557        assert!(entry.content_flags.has_no_name_hash());
558        assert_eq!(entry.name_hash, None);
559    }
560
561    #[test]
562    fn mfst_extended_header_size() {
563        // Newer clients may grow the MFST header - the declared header_size
564        // must be honored so blocks are read from the right offset.
565        let mut data = Vec::new();
566        // MFST header (32 bytes, larger than the usual 24)
567        data.extend_from_slice(&MFST_MAGIC_BE.to_le_bytes());
568        data.extend_from_slice(&32u32.to_le_bytes()); // header_size = 32
569        data.extend_from_slice(&2u32.to_le_bytes()); // version = 2
570        data.extend_from_slice(&1u32.to_le_bytes()); // total_file_count
571        data.extend_from_slice(&0u32.to_le_bytes()); // named_file_count
572        data.extend_from_slice(&[0u8; 12]); // extra header fields / padding
573        assert_eq!(data.len(), 32);
574
575        // Block header v2
576        data.extend_from_slice(&1u32.to_le_bytes()); // num_records = 1
577        data.extend_from_slice(&0x2u32.to_le_bytes()); // locale_flags = enUS
578        data.extend_from_slice(&0x8u32.to_le_bytes()); // unk1
579        data.extend_from_slice(&0x10000000u32.to_le_bytes()); // unk2 = NoNameHash
580        data.push(0); // unk3
581
582        data.extend_from_slice(&77i32.to_le_bytes()); // delta (fdid = 77)
583        data.extend_from_slice(&[0xCD; 16]); // ckey
584
585        let root = RootFile::parse(&data).unwrap();
586        assert_eq!(root.format(), RootFormat::MfstV2);
587        assert_eq!(root.len(), 1);
588        let entry = root.find_by_fdid(77, LocaleFlags::EN_US).unwrap();
589        assert_eq!(entry.ckey, [0xCD; 16]);
590    }
591
592    #[test]
593    fn mfst_unsupported_version_errors() {
594        let mut data = Vec::new();
595        data.extend_from_slice(&MFST_MAGIC_BE.to_le_bytes());
596        data.extend_from_slice(&24u32.to_le_bytes()); // header_size
597        data.extend_from_slice(&99u32.to_le_bytes()); // version = 99 (unknown)
598        data.extend_from_slice(&[0u8; 12]);
599
600        let err = match RootFile::parse(&data) {
601            Err(e) => e,
602            Ok(_) => panic!("expected UnsupportedVersion error"),
603        };
604        assert!(matches!(err, CascError::UnsupportedVersion(99)));
605    }
606
607    #[test]
608    fn parse_error_on_empty_data() {
609        let result = RootFile::parse(&[]);
610        assert!(result.is_err());
611    }
612
613    #[test]
614    fn parse_error_on_truncated_block() {
615        let mut data = Vec::new();
616        // MFST header
617        data.extend_from_slice(&MFST_MAGIC_BE.to_le_bytes());
618        data.extend_from_slice(&24u32.to_le_bytes());
619        data.extend_from_slice(&1u32.to_le_bytes());
620        data.extend_from_slice(&1u32.to_le_bytes());
621        data.extend_from_slice(&1u32.to_le_bytes());
622        data.extend_from_slice(&0u32.to_le_bytes());
623        // Block header claiming 1000 records but no body
624        data.extend_from_slice(&1000u32.to_le_bytes());
625        data.extend_from_slice(&0x8u32.to_le_bytes());
626        data.extend_from_slice(&0x2u32.to_le_bytes());
627
628        let result = RootFile::parse(&data);
629        assert!(result.is_err());
630    }
631
632    #[test]
633    fn fdid_count_vs_len() {
634        // Two entries with same fdid but different locales = fdid_count 1, len 2
635        let blocks = vec![
636            (0x8u32, 0x2u32, vec![(50i32, [0x01; 16], Some(0))]),
637            (0x8u32, 0x20u32, vec![(50i32, [0x02; 16], Some(0))]),
638        ];
639        let data = build_root_v1(&blocks);
640        let root = RootFile::parse(&data).unwrap();
641
642        assert_eq!(root.len(), 2);
643        assert_eq!(root.fdid_count(), 1);
644    }
645}