physis/
gamedata.rs

1// SPDX-FileCopyrightText: 2023 Joshua Goins <josh@redstrate.com>
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4use std::collections::HashMap;
5use std::fs;
6use std::fs::{DirEntry, ReadDir};
7use std::path::PathBuf;
8
9use tracing::{debug, warn};
10
11use crate::ByteBuffer;
12use crate::common::{Language, Platform, read_version};
13use crate::exd::EXD;
14use crate::exh::EXH;
15use crate::exl::EXL;
16use crate::patch::{PatchError, ZiPatch};
17use crate::repository::{Category, Repository, string_to_category};
18use crate::sqpack::{IndexEntry, SqPackData, SqPackIndex};
19
20/// Framework for operating on game data.
21pub struct GameData {
22    /// The game directory to operate on.
23    pub game_directory: String,
24
25    /// Repositories in the game directory.
26    pub repositories: Vec<Repository>,
27
28    index_files: HashMap<String, SqPackIndex>,
29}
30
31fn is_valid(path: &str) -> bool {
32    let d = PathBuf::from(path);
33
34    if fs::metadata(d.as_path()).is_err() {
35        warn!("Game directory not found.");
36        return false;
37    }
38
39    true
40}
41
42/// Possible actions to repair game files
43#[derive(Debug)]
44pub enum RepairAction {
45    /// Indicates a version file is missing for a repository
46    VersionFileMissing,
47    /// The version file is missing, but it can be restored via a backup
48    VersionFileCanRestore,
49}
50
51#[derive(Debug)]
52/// Possible errors emitted through the repair process
53pub enum RepairError<'a> {
54    /// Failed to repair a repository
55    FailedRepair(&'a Repository),
56}
57
58impl GameData {
59    /// Read game data from an existing game installation.
60    ///
61    /// This will return _None_ if the game directory is not valid, but it does not check the validity
62    /// of each individual file.
63    ///
64    /// # Example
65    ///
66    /// ```
67    /// # use physis::common::Platform;
68    /// use physis::gamedata::GameData;
69    /// GameData::from_existing(Platform::Win32, "$FFXIV/game");
70    /// ```
71    pub fn from_existing(platform: Platform, directory: &str) -> Option<GameData> {
72        debug!(directory, "Loading game directory");
73
74        match is_valid(directory) {
75            true => {
76                let mut data = Self {
77                    game_directory: String::from(directory),
78                    repositories: vec![],
79                    index_files: HashMap::new(),
80                };
81                data.reload_repositories(platform);
82                Some(data)
83            }
84            false => {
85                warn!("Game data is not valid!");
86                None
87            }
88        }
89    }
90
91    fn reload_repositories(&mut self, platform: Platform) {
92        self.repositories.clear();
93
94        let mut d = PathBuf::from(self.game_directory.as_str());
95
96        // add initial ffxiv directory
97        if let Some(base_repository) =
98            Repository::from_existing_base(platform.clone(), d.to_str().unwrap())
99        {
100            self.repositories.push(base_repository);
101        }
102
103        // add expansions
104        d.push("sqpack");
105
106        if let Ok(repository_paths) = fs::read_dir(d.as_path()) {
107            let repository_paths: ReadDir = repository_paths;
108
109            let repository_paths: Vec<DirEntry> = repository_paths
110                .filter_map(Result::ok)
111                .filter(|s| s.file_type().unwrap().is_dir())
112                .collect();
113
114            for repository_path in repository_paths {
115                if let Some(expansion_repository) = Repository::from_existing_expansion(
116                    platform.clone(),
117                    repository_path.path().to_str().unwrap(),
118                ) {
119                    self.repositories.push(expansion_repository);
120                }
121            }
122        }
123
124        self.repositories.sort();
125    }
126
127    fn get_dat_file(&self, path: &str, chunk: u8, data_file_id: u32) -> Option<SqPackData> {
128        let (repository, category) = self.parse_repository_category(path).unwrap();
129
130        let dat_path: PathBuf = [
131            self.game_directory.clone(),
132            "sqpack".to_string(),
133            repository.name.clone(),
134            repository.dat_filename(chunk, category, data_file_id),
135        ]
136        .iter()
137        .collect();
138
139        SqPackData::from_existing(dat_path.to_str()?)
140    }
141
142    /// Checks if a file located at `path` exists.
143    ///
144    /// # Example
145    ///
146    /// ```should_panic
147    /// # use physis::common::Platform;
148    /// use physis::gamedata::GameData;
149    /// # let mut game = GameData::from_existing(Platform::Win32, "SquareEnix/Final Fantasy XIV - A Realm Reborn/game").unwrap();
150    /// if game.exists("exd/cid.exl") {
151    ///     println!("Cid really does exist!");
152    /// } else {
153    ///     println!("Oh noes!");
154    /// }
155    /// ```
156    pub fn exists(&mut self, path: &str) -> bool {
157        let Some(_) = self.get_index_filenames(path) else {
158            return false;
159        };
160
161        self.find_entry(path).is_some()
162    }
163
164    /// Extracts the file located at `path`. This is returned as an in-memory buffer, and will usually
165    /// have to be further parsed.
166    ///
167    /// # Example
168    ///
169    /// ```should_panic
170    /// # use physis::gamedata::GameData;
171    /// # use std::io::Write;
172    /// use physis::common::Platform;
173    /// # let mut game = GameData::from_existing(Platform::Win32, "SquareEnix/Final Fantasy XIV - A Realm Reborn/game").unwrap();
174    /// let data = game.extract("exd/root.exl").unwrap();
175    ///
176    /// let mut file = std::fs::File::create("root.exl").unwrap();
177    /// file.write(data.as_slice()).unwrap();
178    /// ```
179    pub fn extract(&mut self, path: &str) -> Option<ByteBuffer> {
180        debug!(file = path, "Extracting file");
181
182        let slice = self.find_entry(path);
183        match slice {
184            Some((entry, chunk)) => {
185                let mut dat_file = self.get_dat_file(path, chunk, entry.data_file_id.into())?;
186
187                dat_file.read_from_offset(entry.offset)
188            }
189            None => None,
190        }
191    }
192
193    /// Finds the offset inside of the DAT file for `path`.
194    pub fn find_offset(&mut self, path: &str) -> Option<u64> {
195        let slice = self.find_entry(path);
196        slice.map(|(entry, _)| entry.offset)
197    }
198
199    /// Parses a path structure and spits out the corresponding category and repository.
200    fn parse_repository_category(&self, path: &str) -> Option<(&Repository, Category)> {
201        let tokens = path.split_once('/')?;
202
203        let repository_token = tokens.1;
204
205        for repository in &self.repositories {
206            if repository.name == repository_token {
207                return Some((repository, string_to_category(tokens.0)?));
208            }
209        }
210
211        Some((&self.repositories[0], string_to_category(tokens.0)?))
212    }
213
214    fn get_index_filenames(&self, path: &str) -> Option<Vec<(String, u8)>> {
215        let (repository, category) = self.parse_repository_category(path)?;
216
217        let mut index_filenames = vec![];
218
219        for chunk in 0..255 {
220            let index_path: PathBuf = [
221                &self.game_directory,
222                "sqpack",
223                &repository.name,
224                &repository.index_filename(chunk, category),
225            ]
226            .iter()
227            .collect();
228
229            index_filenames.push((index_path.into_os_string().into_string().unwrap(), chunk));
230
231            let index2_path: PathBuf = [
232                &self.game_directory,
233                "sqpack",
234                &repository.name,
235                &repository.index2_filename(chunk, category),
236            ]
237            .iter()
238            .collect();
239
240            index_filenames.push((index2_path.into_os_string().into_string().unwrap(), chunk));
241        }
242
243        Some(index_filenames)
244    }
245
246    /// Read an excel sheet by name (e.g. "Achievement")
247    pub fn read_excel_sheet_header(&mut self, name: &str) -> Option<EXH> {
248        let root_exl_file = self.extract("exd/root.exl")?;
249
250        let root_exl = EXL::from_existing(&root_exl_file)?;
251
252        for (row, _) in root_exl.entries {
253            if row == name {
254                let new_filename = name.to_lowercase();
255
256                let path = format!("exd/{new_filename}.exh");
257
258                return EXH::from_existing(&self.extract(&path)?);
259            }
260        }
261
262        None
263    }
264
265    /// Returns all known sheet names listed in the root list
266    pub fn get_all_sheet_names(&mut self) -> Option<Vec<String>> {
267        let root_exl_file = self.extract("exd/root.exl")?;
268
269        let root_exl = EXL::from_existing(&root_exl_file)?;
270
271        let mut names = vec![];
272        for (row, _) in root_exl.entries {
273            names.push(row);
274        }
275
276        Some(names)
277    }
278
279    /// Read an excel sheet
280    pub fn read_excel_sheet(
281        &mut self,
282        name: &str,
283        exh: &EXH,
284        language: Language,
285        page: usize,
286    ) -> Option<EXD> {
287        let exd_path = format!(
288            "exd/{}",
289            EXD::calculate_filename(name, language, &exh.pages[page])
290        );
291
292        let exd_file = self.extract(&exd_path)?;
293
294        EXD::from_existing(&exd_file)
295    }
296
297    /// Applies the patch to game data and returns any errors it encounters. This function will not update the version in the GameData struct.
298    pub fn apply_patch(&self, patch_path: &str) -> Result<(), PatchError> {
299        ZiPatch::apply(&self.game_directory, patch_path)
300    }
301
302    /// Detects whether or not the game files need a repair, right now it only checks for invalid
303    /// version files.
304    /// If the repair is needed, a list of invalid repositories is given.
305    pub fn needs_repair(&self) -> Option<Vec<(&Repository, RepairAction)>> {
306        let mut repositories: Vec<(&Repository, RepairAction)> = Vec::new();
307        for repository in &self.repositories {
308            if repository.version.is_none() {
309                // Check to see if a .bck file is created, as we might be able to use that
310                let ver_bak_path: PathBuf = [
311                    self.game_directory.clone(),
312                    "sqpack".to_string(),
313                    repository.name.clone(),
314                    format!("{}.bck", repository.name),
315                ]
316                .iter()
317                .collect();
318
319                let repair_action = if read_version(&ver_bak_path).is_some() {
320                    RepairAction::VersionFileCanRestore
321                } else {
322                    RepairAction::VersionFileMissing
323                };
324
325                repositories.push((repository, repair_action));
326            }
327        }
328
329        if repositories.is_empty() {
330            None
331        } else {
332            Some(repositories)
333        }
334    }
335
336    /// Performs the repair, assuming any damaging effects it may have
337    /// Returns true only if all actions were taken are successful.
338    /// NOTE: This is a destructive operation, especially for InvalidVersion errors.
339    pub fn perform_repair<'a>(
340        &self,
341        repositories: &Vec<(&'a Repository, RepairAction)>,
342    ) -> Result<(), RepairError<'a>> {
343        for (repository, action) in repositories {
344            let ver_path: PathBuf = [
345                self.game_directory.clone(),
346                "sqpack".to_string(),
347                repository.name.clone(),
348                format!("{}.ver", repository.name),
349            ]
350            .iter()
351            .collect();
352
353            let new_version: String = match action {
354                RepairAction::VersionFileMissing => {
355                    let repo_path: PathBuf = [
356                        self.game_directory.clone(),
357                        "sqpack".to_string(),
358                        repository.name.clone(),
359                    ]
360                    .iter()
361                    .collect();
362
363                    fs::remove_dir_all(&repo_path)
364                        .ok()
365                        .ok_or(RepairError::FailedRepair(repository))?;
366
367                    fs::create_dir_all(&repo_path)
368                        .ok()
369                        .ok_or(RepairError::FailedRepair(repository))?;
370
371                    "2012.01.01.0000.0000".to_string() // TODO: is this correct for expansions?
372                }
373                RepairAction::VersionFileCanRestore => {
374                    let ver_bak_path: PathBuf = [
375                        self.game_directory.clone(),
376                        "sqpack".to_string(),
377                        repository.name.clone(),
378                        format!("{}.bck", repository.name),
379                    ]
380                    .iter()
381                    .collect();
382
383                    read_version(&ver_bak_path).ok_or(RepairError::FailedRepair(repository))?
384                }
385            };
386
387            fs::write(ver_path, new_version)
388                .ok()
389                .ok_or(RepairError::FailedRepair(repository))?;
390        }
391
392        Ok(())
393    }
394
395    fn cache_index_file(&mut self, filename: &str) {
396        if !self.index_files.contains_key(filename) {
397            if let Some(index_file) = SqPackIndex::from_existing(filename) {
398                self.index_files.insert(filename.to_string(), index_file);
399            }
400        }
401    }
402
403    fn get_index_file(&self, filename: &str) -> Option<&SqPackIndex> {
404        self.index_files.get(filename)
405    }
406
407    fn find_entry(&mut self, path: &str) -> Option<(IndexEntry, u8)> {
408        let index_paths = self.get_index_filenames(path)?;
409
410        for (index_path, chunk) in index_paths {
411            self.cache_index_file(&index_path);
412
413            if let Some(index_file) = self.get_index_file(&index_path) {
414                if let Some(entry) = index_file.find_entry(path) {
415                    return Some((entry, chunk));
416                }
417            }
418        }
419
420        None
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use crate::repository::Category::EXD;
427
428    use super::*;
429
430    fn common_setup_data() -> GameData {
431        let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
432        d.push("resources/tests");
433        d.push("valid_sqpack");
434        d.push("game");
435
436        GameData::from_existing(Platform::Win32, d.to_str().unwrap()).unwrap()
437    }
438
439    #[test]
440    fn repository_ordering() {
441        let data = common_setup_data();
442
443        assert_eq!(data.repositories[0].name, "ffxiv");
444        assert_eq!(data.repositories[1].name, "ex1");
445        assert_eq!(data.repositories[2].name, "ex2");
446    }
447
448    #[test]
449    fn repository_and_category_parsing() {
450        let data = common_setup_data();
451
452        assert_eq!(
453            data.parse_repository_category("exd/root.exl").unwrap(),
454            (&data.repositories[0], EXD)
455        );
456        assert!(
457            data.parse_repository_category("what/some_font.dat")
458                .is_none()
459        );
460    }
461}