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 = path.split_once('/')?;
203
204        let repository_token = tokens.1;
205
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        Some((&self.repositories[0], string_to_category(tokens.0)?))
213    }
214
215    fn get_index_filenames(&self, path: &str) -> Option<Vec<(String, u8)>> {
216        let (repository, category) = self.parse_repository_category(path)?;
217
218        let mut index_filenames = vec![];
219
220        for chunk in 0..255 {
221            let index_path: PathBuf = [
222                &self.game_directory,
223                "sqpack",
224                &repository.name,
225                &repository.index_filename(chunk, category),
226            ]
227            .iter()
228            .collect();
229
230            index_filenames.push((index_path.into_os_string().into_string().unwrap(), chunk));
231
232            let index2_path: PathBuf = [
233                &self.game_directory,
234                "sqpack",
235                &repository.name,
236                &repository.index2_filename(chunk, category),
237            ]
238            .iter()
239            .collect();
240
241            index_filenames.push((index2_path.into_os_string().into_string().unwrap(), chunk));
242        }
243
244        Some(index_filenames)
245    }
246
247    /// Read an excel sheet by name (e.g. "Achievement")
248    pub fn read_excel_sheet_header(&mut self, name: &str) -> Option<EXH> {
249        let root_exl_file = self.extract("exd/root.exl")?;
250
251        let root_exl = EXL::from_existing(&root_exl_file)?;
252
253        for (row, _) in root_exl.entries {
254            if row == name {
255                let new_filename = name.to_lowercase();
256
257                let path = format!("exd/{new_filename}.exh");
258
259                return EXH::from_existing(&self.extract(&path)?);
260            }
261        }
262
263        None
264    }
265
266    /// Returns all known sheet names listed in the root list
267    pub fn get_all_sheet_names(&mut self) -> Option<Vec<String>> {
268        let root_exl_file = self.extract("exd/root.exl")?;
269
270        let root_exl = EXL::from_existing(&root_exl_file)?;
271
272        let mut names = vec![];
273        for (row, _) in root_exl.entries {
274            names.push(row);
275        }
276
277        Some(names)
278    }
279
280    /// Read an excel sheet
281    pub fn read_excel_sheet(
282        &mut self,
283        name: &str,
284        exh: &EXH,
285        language: Language,
286        page: usize,
287    ) -> Option<EXD> {
288        let exd_path = format!(
289            "exd/{}",
290            EXD::calculate_filename(name, language, &exh.pages[page])
291        );
292
293        let exd_file = self.extract(&exd_path)?;
294
295        EXD::from_existing(&exh, &exd_file)
296    }
297
298    /// Applies the patch to game data and returns any errors it encounters. This function will not update the version in the GameData struct.
299    pub fn apply_patch(&self, patch_path: &str) -> Result<(), PatchError> {
300        ZiPatch::apply(&self.game_directory, patch_path)
301    }
302
303    /// Detects whether or not the game files need a repair, right now it only checks for invalid
304    /// version files.
305    /// If the repair is needed, a list of invalid repositories is given.
306    pub fn needs_repair(&self) -> Option<Vec<(&Repository, RepairAction)>> {
307        let mut repositories: Vec<(&Repository, RepairAction)> = Vec::new();
308        for repository in &self.repositories {
309            if repository.version.is_none() {
310                // Check to see if a .bck file is created, as we might be able to use that
311                let ver_bak_path: PathBuf = [
312                    self.game_directory.clone(),
313                    "sqpack".to_string(),
314                    repository.name.clone(),
315                    format!("{}.bck", repository.name),
316                ]
317                .iter()
318                .collect();
319
320                let repair_action = if read_version(&ver_bak_path).is_some() {
321                    RepairAction::VersionFileCanRestore
322                } else {
323                    RepairAction::VersionFileMissing
324                };
325
326                repositories.push((repository, repair_action));
327            }
328        }
329
330        if repositories.is_empty() {
331            None
332        } else {
333            Some(repositories)
334        }
335    }
336
337    /// Performs the repair, assuming any damaging effects it may have
338    /// Returns true only if all actions were taken are successful.
339    /// NOTE: This is a destructive operation, especially for InvalidVersion errors.
340    pub fn perform_repair<'a>(
341        &self,
342        repositories: &Vec<(&'a Repository, RepairAction)>,
343    ) -> Result<(), RepairError<'a>> {
344        for (repository, action) in repositories {
345            let ver_path: PathBuf = [
346                self.game_directory.clone(),
347                "sqpack".to_string(),
348                repository.name.clone(),
349                format!("{}.ver", repository.name),
350            ]
351            .iter()
352            .collect();
353
354            let new_version: String = match action {
355                RepairAction::VersionFileMissing => {
356                    let repo_path: PathBuf = [
357                        self.game_directory.clone(),
358                        "sqpack".to_string(),
359                        repository.name.clone(),
360                    ]
361                    .iter()
362                    .collect();
363
364                    fs::remove_dir_all(&repo_path)
365                        .ok()
366                        .ok_or(RepairError::FailedRepair(repository))?;
367
368                    fs::create_dir_all(&repo_path)
369                        .ok()
370                        .ok_or(RepairError::FailedRepair(repository))?;
371
372                    "2012.01.01.0000.0000".to_string() // TODO: is this correct for expansions?
373                }
374                RepairAction::VersionFileCanRestore => {
375                    let ver_bak_path: PathBuf = [
376                        self.game_directory.clone(),
377                        "sqpack".to_string(),
378                        repository.name.clone(),
379                        format!("{}.bck", repository.name),
380                    ]
381                    .iter()
382                    .collect();
383
384                    read_version(&ver_bak_path).ok_or(RepairError::FailedRepair(repository))?
385                }
386            };
387
388            fs::write(ver_path, new_version)
389                .ok()
390                .ok_or(RepairError::FailedRepair(repository))?;
391        }
392
393        Ok(())
394    }
395
396    fn cache_index_file(&mut self, filename: &str) {
397        if !self.index_files.contains_key(filename) {
398            if let Some(index_file) = SqPackIndex::from_existing(filename) {
399                self.index_files.insert(filename.to_string(), index_file);
400            }
401        }
402    }
403
404    fn get_index_file(&self, filename: &str) -> Option<&SqPackIndex> {
405        self.index_files.get(filename)
406    }
407
408    fn find_entry(&mut self, path: &str) -> Option<(IndexEntry, u8)> {
409        let index_paths = self.get_index_filenames(path)?;
410
411        for (index_path, chunk) in index_paths {
412            self.cache_index_file(&index_path);
413
414            if let Some(index_file) = self.get_index_file(&index_path) {
415                if let Some(entry) = index_file.find_entry(path) {
416                    return Some((entry, chunk));
417                }
418            }
419        }
420
421        None
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use crate::repository::Category::EXD;
428
429    use super::*;
430
431    fn common_setup_data() -> GameData {
432        let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
433        d.push("resources/tests");
434        d.push("valid_sqpack");
435        d.push("game");
436
437        GameData::from_existing(Platform::Win32, d.to_str().unwrap())
438    }
439
440    #[test]
441    fn repository_ordering() {
442        let data = common_setup_data();
443
444        assert_eq!(data.repositories[0].name, "ffxiv");
445        assert_eq!(data.repositories[1].name, "ex1");
446        assert_eq!(data.repositories[2].name, "ex2");
447    }
448
449    #[test]
450    fn repository_and_category_parsing() {
451        let data = common_setup_data();
452
453        assert_eq!(
454            data.parse_repository_category("exd/root.exl").unwrap(),
455            (&data.repositories[0], EXD)
456        );
457        assert!(
458            data.parse_repository_category("what/some_font.dat")
459                .is_none()
460        );
461    }
462}