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