tact_parser/
wow_root.rs

1//! Implementation of the [WoW TACT Root][0] file format (`TSFM` / `MFST`).
2//!
3//! This is sometimes called the CASC Root - but CASC has its own file formats
4//! for as they appear on disk.
5//!
6//! [0]: https://wowdev.wiki/TACT#Root
7
8use crate::{Error, Result, ioutils::ReadInt, utils::jenkins3_hashpath};
9use modular_bitfield::{bitfield, prelude::*};
10use std::{
11    collections::{BTreeMap, HashMap},
12    fmt::Debug,
13    io::{ErrorKind, Read, Seek},
14    ops::BitAnd,
15};
16
17const TACT_MAGIC: &[u8; 4] = b"TSFM";
18const MD5_LENGTH: usize = 16;
19pub type Md5 = [u8; MD5_LENGTH];
20
21#[derive(Debug)]
22pub struct WowRootHeader {
23    pub use_old_record_format: bool,
24    pub version: u32,
25    pub total_file_count: u32,
26    pub named_file_count: u32,
27    pub allow_non_named_files: bool,
28}
29
30impl WowRootHeader {
31    /// Parses a WoW Root header.
32    pub fn parse<R: Read + Seek>(f: &mut R) -> Result<Self> {
33        let mut magic = [0; TACT_MAGIC.len()];
34        f.read_exact(&mut magic)?;
35        if &magic != TACT_MAGIC {
36            // Pre-8.2 WoW root file (used by Classic Era)
37            f.seek_relative(-(TACT_MAGIC.len() as i64))?;
38            return Ok(Self {
39                use_old_record_format: true,
40                version: 0,
41                total_file_count: 0,
42                named_file_count: 0,
43                allow_non_named_files: true,
44            });
45        }
46
47        // See if there's a header size here
48        let mut header_size = f.read_u32le()?;
49        let mut version = 0;
50        let total_file_count;
51
52        if header_size == 0x18 {
53            // Format >= 10.1.7.50893
54            version = f.read_u32le()?;
55            total_file_count = f.read_u32le()?;
56        } else {
57            total_file_count = header_size;
58            header_size = 0;
59        }
60        let named_file_count = f.read_u32le()?;
61
62        if header_size == 0x18 {
63            // skip padding
64            f.seek_relative(4)?;
65        }
66
67        Ok(Self {
68            use_old_record_format: false,
69            allow_non_named_files: total_file_count != named_file_count,
70            version,
71            total_file_count,
72            named_file_count,
73        })
74    }
75}
76
77pub struct CasBlock {
78    pub flags: LocaleContentFlags,
79    pub fid_md5: Option<Vec<(u32, Md5)>>,
80    pub name_hash_fid: Option<Vec<(u64, u32)>>,
81}
82
83impl std::fmt::Debug for CasBlock {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.debug_struct("CasBlock")
86            .field("context", &self.flags)
87            .field("fid_md5.len", &self.fid_md5.as_ref().map(|v| v.len()))
88            .field(
89                "name_hash_fid.len",
90                &self.name_hash_fid.as_ref().map(|v| v.len()),
91            )
92            .finish()
93    }
94}
95
96impl CasBlock {
97    pub fn parse<R: Read + Seek>(
98        f: &mut R,
99        header: &WowRootHeader,
100        only_locale: LocaleFlags,
101    ) -> Result<Self> {
102        let num_records = f.read_u32le()? as usize;
103
104        let flags = if header.version == 2 {
105            let locale = LocaleFlags::from(f.read_u32le()?);
106            let v1 = f.read_u32le()?;
107            let v2 = f.read_u32le()?;
108            let v3 = f.read_u8()?;
109
110            LocaleContentFlags {
111                locale,
112                content: ContentFlags::from(v1 | v2 | (u32::from(v3) << 17)),
113            }
114        } else {
115            LocaleContentFlags {
116                content: ContentFlags::from(f.read_u32le()?),
117                locale: LocaleFlags::from(f.read_u32le()?),
118            }
119        };
120
121        if num_records == 0 {
122            // Ignore empty blocks without seeking
123            return Ok(Self {
124                flags,
125                fid_md5: None,
126                name_hash_fid: None,
127            });
128        }
129
130        let has_name_hashes = header.use_old_record_format
131            || !(header.allow_non_named_files && flags.content.no_name_hash());
132        if !flags.locale.all() && !(flags.locale & only_locale).any() {
133            // Skip the section, not for us.
134            // The size of the section is the same in both old and new record
135            // format, just arranged differently.
136            let record_length =
137                size_of::<u32>() + MD5_LENGTH + if has_name_hashes { size_of::<u64>() } else { 0 };
138            f.seek_relative((num_records * record_length) as i64)?;
139
140            return Ok(Self {
141                flags,
142                fid_md5: None,
143                name_hash_fid: None,
144            });
145        }
146
147        // Convert file_id_deltas -> absolute file_id
148        let mut file_ids: Vec<u32> = Vec::with_capacity(num_records);
149        let mut file_id = 0u32;
150        for i in 0..num_records {
151            let delta = f.read_i32le()?;
152
153            file_id = if i == 0 {
154                u32::try_from(delta).map_err(|_| Error::FileIdDeltaOverflow)?
155            } else {
156                (file_id)
157                    .checked_add_signed(1 + delta)
158                    .ok_or(Error::FileIdDeltaOverflow)?
159            };
160
161            file_ids.push(file_id);
162        }
163
164        // Collect content MD5s
165        let mut fid_md5: Vec<(u32, Md5)> = Vec::with_capacity(num_records);
166        let mut name_hash_fid: Option<Vec<(u64, u32)>> = None;
167
168        if header.use_old_record_format {
169            let mut o = Vec::with_capacity(num_records);
170
171            for file_id in file_ids {
172                let mut md5 = [0; MD5_LENGTH];
173                f.read_exact(&mut md5)?;
174                fid_md5.push((file_id, md5));
175                o.push((f.read_u64le()?, file_id));
176            }
177
178            name_hash_fid = Some(o);
179        } else {
180            for &file_id in file_ids.iter() {
181                let mut md5 = [0; MD5_LENGTH];
182                f.read_exact(&mut md5)?;
183                fid_md5.push((file_id, md5));
184            }
185
186            if has_name_hashes {
187                let mut o = Vec::with_capacity(num_records);
188
189                for &file_id in file_ids.iter() {
190                    let hash = f.read_u64le()?;
191                    o.push((hash, file_id));
192                }
193
194                name_hash_fid = Some(o);
195            }
196        }
197
198        Ok(Self {
199            flags,
200            fid_md5: Some(fid_md5),
201            name_hash_fid,
202        })
203    }
204}
205
206#[derive(Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
207pub struct LocaleContentFlags {
208    pub locale: LocaleFlags,
209    pub content: ContentFlags,
210}
211
212/// Bitmask of locales the content should be used for.
213#[bitfield(bytes = 4)]
214#[derive(PartialEq, Eq, Debug, Copy, Clone, Hash, PartialOrd, Ord)]
215#[repr(u32)]
216pub struct LocaleFlags {
217    #[skip]
218    __: B1,
219    pub en_us: bool, // 0x2
220    #[skip]
221    __: B1,
222    pub ko_kr: bool, // 0x4
223
224    pub fr_fr: bool, // 0x10
225    pub de_de: bool, // 0x20
226    pub zh_cn: bool, // 0x40
227    pub es_es: bool, // 0x80
228
229    pub zh_tw: bool, // 0x100
230    pub en_gb: bool, // 0x200
231    pub en_cn: bool, // 0x400
232    pub en_tw: bool, // 0x800
233
234    pub es_mx: bool, // 0x1000
235    pub ru_ru: bool, // 0x2000
236    pub pt_br: bool, // 0x4000
237    pub it_it: bool, // 0x8000
238
239    pub pt_pt: bool, // 0x10000
240    #[skip]
241    __: B15,
242}
243
244impl LocaleFlags {
245    /// `LocaleFlags` which sets all locales to `true`.
246    pub fn any_locale() -> Self {
247        LocaleFlags::from(0xffffffff)
248    }
249
250    /// `true` if the flags indicate all locales.
251    pub fn all(&self) -> bool {
252        self == &Self::any_locale()
253    }
254
255    /// `true` if there is at least one locale flag set.
256    pub fn any(&self) -> bool {
257        u32::from(*self) != 0
258    }
259}
260
261impl BitAnd for LocaleFlags {
262    type Output = LocaleFlags;
263
264    fn bitand(self, rhs: Self) -> Self::Output {
265        Self::from(u32::from(self) & u32::from(rhs))
266    }
267}
268
269/// TACT content flags on the WoW root index.
270///
271/// Reference: [WoWDev Wiki](https://wowdev.wiki/TACT#Root)
272#[bitfield(bytes = 4)]
273#[derive(PartialEq, Eq, Debug, Copy, Clone, Hash, PartialOrd, Ord)]
274#[repr(u32)]
275pub struct ContentFlags {
276    /// Is high-res texture (Cataclysm 4.4.0 beta).
277    pub high_res_texture: bool, // 0x1
278    #[skip]
279    __: B1,
280    /// File is in install manifest.
281    pub install: bool, // 0x4
282    /// Non-Windows clients should ignore this file.
283    pub windows: bool, // 0x8
284
285    /// Non-macOS clients should ignore this file.
286    pub macos: bool, // 0x10
287    /// `x86_32` binary.
288    pub x86_32: bool, // 0x20
289    /// `x86_64` binary.
290    pub x86_64: bool, // 0x40
291    /// Low violence variant.
292    pub low_violence: bool, // 0x80
293
294    /// Non-mystery-platform clients should ignore this file.
295    pub mystery_platform: bool, // 0x100
296    #[skip]
297    __: B2,
298    /// Only set for `UpdatePlugin.{dll,dylib}`
299    pub update_plugin: bool, // 0x800
300
301    #[skip]
302    __: B3,
303    /// `aarch64` / ARM64 binary.
304    pub aarch64: bool, // 0x8000
305
306    #[skip]
307    __: B11,
308    pub encrypted: bool, // 0x8000000
309
310    pub no_name_hash: bool, // 0x10000000
311    /// Non-1280px wide cinematics.
312    pub uncommon_resolution: bool, // 0x20000000
313    pub bundle: bool,       // 0x40000000
314    pub no_compression: bool, // 0x80000000
315}
316
317/// [WoW TACT Root][0] parser.
318///
319/// [0]: https://wowdev.wiki/TACT#Root
320pub struct WowRoot {
321    /// Mapping of File ID -> Flags + MD5
322    pub fid_md5: BTreeMap<u32, BTreeMap<LocaleContentFlags, Md5>>,
323
324    /// Mapping of `jenkins3_hashpath` -> file ID.
325    pub name_hash_fid: HashMap<u64, u32>,
326}
327
328impl WowRoot {
329    /// Parse a WoW TACT root file.
330    pub fn parse<R: Read + Seek>(f: &mut R, only_locale: LocaleFlags) -> Result<Self> {
331        let header = WowRootHeader::parse(f)?;
332        let mut o = Self {
333            fid_md5: BTreeMap::new(),
334            name_hash_fid: HashMap::new(),
335        };
336
337        // Keep reading to EOF
338        loop {
339            match CasBlock::parse(f, &header, only_locale) {
340                Ok(block) => {
341                    // Add fids and name hashes to our collection
342                    // TODO: make CasBlock push this directly, rather than
343                    // allocating many temporary Vecs.
344                    if let Some(fid_md5) = block.fid_md5 {
345                        for (k, v) in fid_md5 {
346                            if let Some(e) = o.fid_md5.get_mut(&k) {
347                                assert!(e.insert(block.flags, v).is_none());
348                            } else {
349                                o.fid_md5.insert(k, BTreeMap::from([(block.flags, v)]));
350                            }
351                        }
352                    }
353
354                    if let Some(name_hash_fid) = block.name_hash_fid {
355                        for (k, v) in name_hash_fid {
356                            o.name_hash_fid.entry(k).or_insert(v);
357                        }
358                    }
359                }
360
361                Err(Error::IOError(e)) if e.kind() == ErrorKind::UnexpectedEof => {
362                    break;
363                }
364
365                Err(e) => return Err(e),
366            }
367        }
368        Ok(o)
369    }
370
371    /// Gets a file ID for the given `path`.
372    ///
373    /// Returns `None` if the file cannot be found in the root index.
374    pub fn get_fid(&self, path: &str) -> Option<u32> {
375        let hash = jenkins3_hashpath(path);
376        self.name_hash_fid.get(&hash).copied()
377    }
378}
379
380impl Debug for WowRoot {
381    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382        f.debug_struct("WowRoot")
383            .field("fid_md5.len", &self.fid_md5.len())
384            .field("name_hash_fid.len", &self.name_hash_fid.len())
385            .finish()
386    }
387}