tact_parser/
install.rs

1//! Install manifest parser for TACT
2//!
3//! The install manifest lists files that need to be installed for the game to run.
4//! Files are associated with tags (e.g., "Windows", "Mac", "enUS") using a bitmask system.
5
6use byteorder::{BigEndian, ReadBytesExt};
7use std::io::{Cursor, Read};
8use tracing::{debug, trace};
9
10use crate::utils::read_cstring_from;
11use crate::{Error, Result};
12
13/// Magic bytes for install manifest: "IN"
14const INSTALL_MAGIC: [u8; 2] = [0x49, 0x4E]; // 'I', 'N'
15
16/// Install manifest header
17#[derive(Debug, Clone)]
18pub struct InstallHeader {
19    /// Magic bytes "IN"
20    pub magic: [u8; 2],
21    /// Version (should be 1)
22    pub version: u8,
23    /// Hash size (usually 16 for MD5)
24    pub hash_size: u8,
25    /// Number of tags
26    pub tag_count: u16,
27    /// Number of file entries
28    pub entry_count: u32,
29}
30
31/// Install tag information
32#[derive(Debug, Clone)]
33pub struct InstallTag {
34    /// Tag name (e.g., "Windows", "Mac", "enUS")
35    pub name: String,
36    /// Tag type/flags
37    pub tag_type: u16,
38    /// Bitmask indicating which files have this tag
39    pub files_mask: Vec<bool>,
40}
41
42/// Install file entry
43#[derive(Debug, Clone)]
44pub struct InstallEntry {
45    /// File path relative to game root
46    pub path: String,
47    /// Content key (CKey)
48    pub ckey: Vec<u8>,
49    /// File size
50    pub size: u32,
51    /// Tags associated with this file
52    pub tags: Vec<String>,
53}
54
55/// Install manifest
56pub struct InstallManifest {
57    /// File header
58    pub header: InstallHeader,
59    /// List of tags
60    pub tags: Vec<InstallTag>,
61    /// List of file entries
62    pub entries: Vec<InstallEntry>,
63}
64
65/// Common platform tags
66#[derive(Debug, Clone, PartialEq)]
67pub enum Platform {
68    Windows,
69    Mac,
70    Linux,
71    All,
72}
73
74impl Platform {
75    /// Get the tag name for this platform
76    pub fn tag_name(&self) -> &str {
77        match self {
78            Platform::Windows => "Windows",
79            Platform::Mac => "OSX",
80            Platform::Linux => "Linux",
81            Platform::All => "",
82        }
83    }
84}
85
86impl InstallManifest {
87    /// Parse an install manifest from raw data
88    pub fn parse(data: &[u8]) -> Result<Self> {
89        let mut cursor = Cursor::new(data);
90
91        // Parse header
92        let header = Self::parse_header(&mut cursor)?;
93        debug!(
94            "Parsed install header: version={}, tags={}, entries={}",
95            header.version, header.tag_count, header.entry_count
96        );
97
98        // Calculate bytes per tag for bitmask
99        let bytes_per_tag = (header.entry_count.div_ceil(8)) as usize;
100
101        // Parse tags
102        let mut tags = Vec::with_capacity(header.tag_count as usize);
103        for i in 0..header.tag_count {
104            let name = read_cstring_from(&mut cursor)?;
105            let tag_type = cursor.read_u16::<BigEndian>()?;
106
107            // Read bitmask for this tag
108            let mut mask_bytes = vec![0u8; bytes_per_tag];
109            cursor.read_exact(&mut mask_bytes)?;
110
111            // Convert bytes to bool vector
112            let mut files_mask = Vec::with_capacity(header.entry_count as usize);
113            for byte in mask_bytes {
114                for bit in 0..8 {
115                    if files_mask.len() < header.entry_count as usize {
116                        files_mask.push((byte & (1 << bit)) != 0);
117                    }
118                }
119            }
120
121            trace!(
122                "Tag {}: name='{}', type={:#06x}, files_with_tag={}",
123                i,
124                name,
125                tag_type,
126                files_mask.iter().filter(|&&b| b).count()
127            );
128
129            tags.push(InstallTag {
130                name,
131                tag_type,
132                files_mask,
133            });
134        }
135
136        // Parse file entries
137        let mut entries = Vec::with_capacity(header.entry_count as usize);
138        for i in 0..header.entry_count {
139            let path = read_cstring_from(&mut cursor)?;
140
141            let mut ckey = vec![0u8; header.hash_size as usize];
142            cursor.read_exact(&mut ckey)?;
143
144            let size = cursor.read_u32::<BigEndian>()?;
145
146            // Resolve tags for this entry
147            let mut entry_tags = Vec::new();
148            for tag in &tags {
149                if tag.files_mask[i as usize] {
150                    entry_tags.push(tag.name.clone());
151                }
152            }
153
154            entries.push(InstallEntry {
155                path,
156                ckey,
157                size,
158                tags: entry_tags,
159            });
160        }
161
162        debug!("Parsed {} install entries", entries.len());
163
164        Ok(InstallManifest {
165            header,
166            tags,
167            entries,
168        })
169    }
170
171    /// Parse the install manifest header
172    fn parse_header<R: Read>(reader: &mut R) -> Result<InstallHeader> {
173        let mut magic = [0u8; 2];
174        reader.read_exact(&mut magic)?;
175
176        if magic != INSTALL_MAGIC {
177            return Err(Error::BadMagic);
178        }
179
180        let version = reader.read_u8()?;
181        let hash_size = reader.read_u8()?;
182        let tag_count = reader.read_u16::<BigEndian>()?;
183        let entry_count = reader.read_u32::<BigEndian>()?;
184
185        Ok(InstallHeader {
186            magic,
187            version,
188            hash_size,
189            tag_count,
190            entry_count,
191        })
192    }
193
194    /// Get all files that have specific tags
195    pub fn get_files_for_tags(&self, required_tags: &[&str]) -> Vec<&InstallEntry> {
196        self.entries
197            .iter()
198            .filter(|entry| {
199                required_tags
200                    .iter()
201                    .all(|tag| entry.tags.contains(&tag.to_string()))
202            })
203            .collect()
204    }
205
206    /// Get all files for a specific platform
207    pub fn get_files_for_platform(&self, platform: Platform) -> Vec<&InstallEntry> {
208        if platform == Platform::All {
209            return self.entries.iter().collect();
210        }
211
212        let tag_name = platform.tag_name();
213        self.get_files_for_tags(&[tag_name])
214    }
215
216    /// Get all unique tags in the manifest
217    pub fn get_all_tags(&self) -> Vec<&str> {
218        self.tags.iter().map(|t| t.name.as_str()).collect()
219    }
220
221    /// Get a specific file by path
222    pub fn get_file_by_path(&self, path: &str) -> Option<&InstallEntry> {
223        self.entries.iter().find(|e| e.path == path)
224    }
225
226    /// Calculate total size for files with specific tags
227    pub fn calculate_size_for_tags(&self, tags: &[&str]) -> u64 {
228        self.get_files_for_tags(tags)
229            .iter()
230            .map(|entry| entry.size as u64)
231            .sum()
232    }
233
234    /// Calculate total size for a platform
235    pub fn calculate_size_for_platform(&self, platform: Platform) -> u64 {
236        self.get_files_for_platform(platform)
237            .iter()
238            .map(|entry| entry.size as u64)
239            .sum()
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn test_install_header_size() {
249        // Header should be exactly 10 bytes
250        let header_size = 2 + 1 + 1 + 2 + 4;
251        assert_eq!(header_size, 10);
252    }
253
254    #[test]
255    fn test_parse_empty_install() {
256        // Create a minimal valid install manifest
257        let mut data = Vec::new();
258
259        // Magic
260        data.extend_from_slice(&INSTALL_MAGIC);
261        // Version
262        data.push(1);
263        // Hash size
264        data.push(16);
265        // Tag count (big-endian!)
266        data.extend_from_slice(&0u16.to_be_bytes());
267        // Entry count (big-endian!)
268        data.extend_from_slice(&0u32.to_be_bytes());
269
270        let result = InstallManifest::parse(&data);
271        assert!(result.is_ok());
272
273        let manifest = result.unwrap();
274        assert_eq!(manifest.header.version, 1);
275        assert_eq!(manifest.header.hash_size, 16);
276        assert_eq!(manifest.tags.len(), 0);
277        assert_eq!(manifest.entries.len(), 0);
278    }
279
280    #[test]
281    fn test_invalid_magic() {
282        let mut data = vec![0xFF, 0xFF]; // Wrong magic
283        data.push(1); // Version
284
285        let result = InstallManifest::parse(&data);
286        assert!(matches!(result, Err(Error::BadMagic)));
287    }
288
289    #[test]
290    fn test_parse_with_tags() {
291        // Create an install manifest with one tag and one file
292        let mut data = Vec::new();
293
294        // Header
295        data.extend_from_slice(&INSTALL_MAGIC);
296        data.push(1); // Version
297        data.push(16); // Hash size
298        data.extend_from_slice(&1u16.to_be_bytes()); // 1 tag
299        data.extend_from_slice(&1u32.to_be_bytes()); // 1 entry
300
301        // Tag
302        data.extend_from_slice(b"Windows\0"); // Tag name
303        data.extend_from_slice(&0u16.to_be_bytes()); // Tag type
304        data.push(0x01); // Bitmask: first file has this tag
305
306        // Entry
307        data.extend_from_slice(b"test.exe\0"); // File path
308        data.extend_from_slice(&[0u8; 16]); // CKey (16 bytes of zeros)
309        data.extend_from_slice(&1024u32.to_be_bytes()); // Size
310
311        let result = InstallManifest::parse(&data);
312        assert!(result.is_ok());
313
314        let manifest = result.unwrap();
315        assert_eq!(manifest.tags.len(), 1);
316        assert_eq!(manifest.tags[0].name, "Windows");
317        assert_eq!(manifest.entries.len(), 1);
318        assert_eq!(manifest.entries[0].path, "test.exe");
319        assert_eq!(manifest.entries[0].size, 1024);
320        assert!(manifest.entries[0].tags.contains(&"Windows".to_string()));
321    }
322
323    #[test]
324    fn test_platform_filtering() {
325        // Create a manifest with Windows and Mac files
326        let mut data = Vec::new();
327
328        // Header
329        data.extend_from_slice(&INSTALL_MAGIC);
330        data.push(1); // Version
331        data.push(16); // Hash size
332        data.extend_from_slice(&2u16.to_be_bytes()); // 2 tags
333        data.extend_from_slice(&2u32.to_be_bytes()); // 2 entries
334
335        // Tag 1: Windows
336        data.extend_from_slice(b"Windows\0");
337        data.extend_from_slice(&0u16.to_be_bytes());
338        data.push(0x01); // First file has Windows tag
339
340        // Tag 2: OSX
341        data.extend_from_slice(b"OSX\0");
342        data.extend_from_slice(&0u16.to_be_bytes());
343        data.push(0x02); // Second file has OSX tag
344
345        // Entry 1: Windows file
346        data.extend_from_slice(b"windows.exe\0");
347        data.extend_from_slice(&[1u8; 16]);
348        data.extend_from_slice(&1000u32.to_be_bytes());
349
350        // Entry 2: Mac file
351        data.extend_from_slice(b"mac.app\0");
352        data.extend_from_slice(&[2u8; 16]);
353        data.extend_from_slice(&2000u32.to_be_bytes());
354
355        let manifest = InstallManifest::parse(&data).unwrap();
356
357        // Test platform filtering
358        let windows_files = manifest.get_files_for_platform(Platform::Windows);
359        assert_eq!(windows_files.len(), 1);
360        assert_eq!(windows_files[0].path, "windows.exe");
361
362        let mac_files = manifest.get_files_for_platform(Platform::Mac);
363        assert_eq!(mac_files.len(), 1);
364        assert_eq!(mac_files[0].path, "mac.app");
365
366        // Test size calculation
367        assert_eq!(
368            manifest.calculate_size_for_platform(Platform::Windows),
369            1000
370        );
371        assert_eq!(manifest.calculate_size_for_platform(Platform::Mac), 2000);
372        assert_eq!(manifest.calculate_size_for_platform(Platform::All), 3000);
373    }
374}