physis/resource/
sqpack.rs

1// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4use std::{
5    collections::HashMap,
6    fs::{self, DirEntry, ReadDir},
7    path::PathBuf,
8};
9
10use crate::{
11    ByteBuffer,
12    common::{Platform, read_version},
13    patch::{PatchError, ZiPatch},
14    repository::{Category, Repository, string_to_category},
15    sqpack::{IndexEntry, SqPackData, SqPackIndex},
16};
17
18use super::Resource;
19
20/// Possible actions to repair game files
21#[derive(Debug)]
22pub enum RepairAction {
23    /// Indicates a version file is missing for a repository
24    VersionFileMissing,
25    /// The version file is missing, but it can be restored via a backup
26    VersionFileCanRestore,
27}
28
29#[derive(Debug)]
30/// Possible errors emitted through the repair process
31pub enum RepairError<'a> {
32    /// Failed to repair a repository
33    FailedRepair(&'a Repository),
34}
35
36/// Used to read files from the retail game, in their SqPack-compressed format.
37pub struct SqPackResource {
38    /// The game directory to operate on.
39    pub game_directory: String,
40
41    /// Repositories in the game directory.
42    pub repositories: Vec<Repository>,
43
44    index_files: HashMap<String, SqPackIndex>,
45}
46
47impl SqPackResource {
48    pub fn from_existing(platform: Platform, directory: &str) -> Self {
49        match is_valid(directory) {
50            true => {
51                let mut data = Self {
52                    game_directory: String::from(directory),
53                    repositories: vec![],
54                    index_files: HashMap::new(),
55                };
56                data.reload_repositories(platform);
57                data
58            }
59            false => {
60                // Game data is not valid! Treating it as a new install...
61                Self {
62                    game_directory: String::from(directory),
63                    repositories: vec![],
64                    index_files: HashMap::new(),
65                }
66            }
67        }
68    }
69
70    fn reload_repositories(&mut self, platform: Platform) {
71        self.repositories.clear();
72
73        let mut d = PathBuf::from(self.game_directory.as_str());
74
75        // add initial ffxiv directory
76        if let Some(base_repository) =
77            Repository::from_existing_base(platform.clone(), d.to_str().unwrap())
78        {
79            self.repositories.push(base_repository);
80        }
81
82        // add expansions
83        d.push("sqpack");
84
85        if let Ok(repository_paths) = fs::read_dir(d.as_path()) {
86            let repository_paths: ReadDir = repository_paths;
87
88            let repository_paths: Vec<DirEntry> = repository_paths
89                .filter_map(Result::ok)
90                .filter(|s| s.file_type().unwrap().is_dir())
91                .collect();
92
93            for repository_path in repository_paths {
94                if let Some(expansion_repository) = Repository::from_existing_expansion(
95                    platform.clone(),
96                    repository_path.path().to_str().unwrap(),
97                ) {
98                    self.repositories.push(expansion_repository);
99                }
100            }
101        }
102
103        self.repositories.sort();
104    }
105
106    fn get_dat_file(&self, path: &str, chunk: u8, data_file_id: u32) -> Option<SqPackData> {
107        let (repository, category) = self.parse_repository_category(path).unwrap();
108
109        let dat_path: PathBuf = [
110            self.game_directory.clone(),
111            "sqpack".to_string(),
112            repository.name.clone(),
113            repository.dat_filename(chunk, category, data_file_id),
114        ]
115        .iter()
116        .collect();
117
118        SqPackData::from_existing(dat_path.to_str()?)
119    }
120
121    /// Finds the offset inside of the DAT file for `path`.
122    pub fn find_offset(&mut self, path: &str) -> Option<u64> {
123        let slice = self.find_entry(path);
124        slice.map(|(entry, _)| entry.offset)
125    }
126
127    /// Parses a path structure and spits out the corresponding category and repository.
128    fn parse_repository_category(&self, path: &str) -> Option<(&Repository, Category)> {
129        if self.repositories.is_empty() {
130            return None;
131        }
132
133        let tokens: Vec<&str> = path.split('/').collect();
134
135        // Search for expansions
136        let repository_token = tokens[1];
137        for repository in &self.repositories {
138            if repository.name == repository_token {
139                return Some((repository, string_to_category(tokens[0])?));
140            }
141        }
142
143        // Fallback to ffxiv
144        Some((&self.repositories[0], string_to_category(tokens[0])?))
145    }
146
147    fn get_index_filenames(&self, path: &str) -> Option<Vec<(String, u8)>> {
148        let (repository, category) = self.parse_repository_category(path)?;
149
150        let mut index_filenames = vec![];
151
152        for chunk in 0..255 {
153            let index_path: PathBuf = [
154                &self.game_directory,
155                "sqpack",
156                &repository.name,
157                &repository.index_filename(chunk, category),
158            ]
159            .iter()
160            .collect();
161
162            index_filenames.push((index_path.into_os_string().into_string().unwrap(), chunk));
163
164            let index2_path: PathBuf = [
165                &self.game_directory,
166                "sqpack",
167                &repository.name,
168                &repository.index2_filename(chunk, category),
169            ]
170            .iter()
171            .collect();
172
173            index_filenames.push((index2_path.into_os_string().into_string().unwrap(), chunk));
174        }
175
176        Some(index_filenames)
177    }
178
179    /// Applies the patch to game data and returns any errors it encounters. This function will not update the version in the GameData struct.
180    pub fn apply_patch(&self, patch_path: &str) -> Result<(), PatchError> {
181        ZiPatch::apply(&self.game_directory, patch_path)
182    }
183
184    /// Detects whether or not the game files need a repair, right now it only checks for invalid
185    /// version files.
186    /// If the repair is needed, a list of invalid repositories is given.
187    pub fn needs_repair(&self) -> Option<Vec<(&Repository, RepairAction)>> {
188        let mut repositories: Vec<(&Repository, RepairAction)> = Vec::new();
189        for repository in &self.repositories {
190            if repository.version.is_none() {
191                // Check to see if a .bck file is created, as we might be able to use that
192                let ver_bak_path: PathBuf = [
193                    self.game_directory.clone(),
194                    "sqpack".to_string(),
195                    repository.name.clone(),
196                    format!("{}.bck", repository.name),
197                ]
198                .iter()
199                .collect();
200
201                let repair_action = if read_version(&ver_bak_path).is_some() {
202                    RepairAction::VersionFileCanRestore
203                } else {
204                    RepairAction::VersionFileMissing
205                };
206
207                repositories.push((repository, repair_action));
208            }
209        }
210
211        if repositories.is_empty() {
212            None
213        } else {
214            Some(repositories)
215        }
216    }
217
218    /// Performs the repair, assuming any damaging effects it may have
219    /// Returns true only if all actions were taken are successful.
220    /// NOTE: This is a destructive operation, especially for InvalidVersion errors.
221    pub fn perform_repair<'a>(
222        &self,
223        repositories: &Vec<(&'a Repository, RepairAction)>,
224    ) -> Result<(), RepairError<'a>> {
225        for (repository, action) in repositories {
226            let ver_path: PathBuf = [
227                self.game_directory.clone(),
228                "sqpack".to_string(),
229                repository.name.clone(),
230                format!("{}.ver", repository.name),
231            ]
232            .iter()
233            .collect();
234
235            let new_version: String = match action {
236                RepairAction::VersionFileMissing => {
237                    let repo_path: PathBuf = [
238                        self.game_directory.clone(),
239                        "sqpack".to_string(),
240                        repository.name.clone(),
241                    ]
242                    .iter()
243                    .collect();
244
245                    fs::remove_dir_all(&repo_path)
246                        .ok()
247                        .ok_or(RepairError::FailedRepair(repository))?;
248
249                    fs::create_dir_all(&repo_path)
250                        .ok()
251                        .ok_or(RepairError::FailedRepair(repository))?;
252
253                    "2012.01.01.0000.0000".to_string() // TODO: is this correct for expansions?
254                }
255                RepairAction::VersionFileCanRestore => {
256                    let ver_bak_path: PathBuf = [
257                        self.game_directory.clone(),
258                        "sqpack".to_string(),
259                        repository.name.clone(),
260                        format!("{}.bck", repository.name),
261                    ]
262                    .iter()
263                    .collect();
264
265                    read_version(&ver_bak_path).ok_or(RepairError::FailedRepair(repository))?
266                }
267            };
268
269            fs::write(ver_path, new_version)
270                .ok()
271                .ok_or(RepairError::FailedRepair(repository))?;
272        }
273
274        Ok(())
275    }
276
277    fn cache_index_file(&mut self, filename: &str) {
278        if !self.index_files.contains_key(filename) {
279            if let Some(index_file) = SqPackIndex::from_existing(filename) {
280                self.index_files.insert(filename.to_string(), index_file);
281            }
282        }
283    }
284
285    fn get_index_file(&self, filename: &str) -> Option<&SqPackIndex> {
286        self.index_files.get(filename)
287    }
288
289    fn find_entry(&mut self, path: &str) -> Option<(IndexEntry, u8)> {
290        let index_paths = self.get_index_filenames(path)?;
291
292        for (index_path, chunk) in index_paths {
293            self.cache_index_file(&index_path);
294
295            if let Some(index_file) = self.get_index_file(&index_path) {
296                if let Some(entry) = index_file.find_entry(path) {
297                    return Some((entry, chunk));
298                }
299            }
300        }
301
302        None
303    }
304}
305
306impl Resource for SqPackResource {
307    fn read(&mut self, path: &str) -> Option<ByteBuffer> {
308        let slice = self.find_entry(path);
309        match slice {
310            Some((entry, chunk)) => {
311                let mut dat_file = self.get_dat_file(path, chunk, entry.data_file_id.into())?;
312
313                dat_file.read_from_offset(entry.offset)
314            }
315            None => None,
316        }
317    }
318
319    fn exists(&mut self, path: &str) -> bool {
320        let Some(_) = self.get_index_filenames(path) else {
321            return false;
322        };
323
324        self.find_entry(path).is_some()
325    }
326}
327
328fn is_valid(path: &str) -> bool {
329    let d = PathBuf::from(path);
330
331    if fs::metadata(d.as_path()).is_err() {
332        return false;
333    }
334
335    true
336}
337
338#[cfg(test)]
339mod tests {
340    use crate::repository::Category::*;
341
342    use super::*;
343
344    fn common_setup_data() -> SqPackResource {
345        let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
346        d.push("resources/tests");
347        d.push("valid_sqpack");
348        d.push("game");
349
350        SqPackResource::from_existing(Platform::Win32, d.to_str().unwrap())
351    }
352
353    #[test]
354    fn repository_ordering() {
355        let data = common_setup_data();
356
357        assert_eq!(data.repositories[0].name, "ffxiv");
358        assert_eq!(data.repositories[1].name, "ex1");
359        assert_eq!(data.repositories[2].name, "ex2");
360    }
361
362    #[test]
363    fn repository_and_category_parsing() {
364        let data = common_setup_data();
365
366        // fallback to ffxiv
367        assert_eq!(
368            data.parse_repository_category("exd/root.exl").unwrap(),
369            (&data.repositories[0], EXD)
370        );
371        // ex1
372        assert_eq!(
373            data.parse_repository_category("bg/ex1/01_roc_r2/twn/r2t1/level/planevent.lgb")
374                .unwrap(),
375            (&data.repositories[1], Background)
376        );
377        // ex2
378        assert_eq!(
379            data.parse_repository_category("bg/ex2/01_gyr_g3/fld/g3fb/level/planner.lgb")
380                .unwrap(),
381            (&data.repositories[2], Background)
382        );
383        // invalid but should still parse I guess
384        assert!(
385            data.parse_repository_category("what/some_font.dat")
386                .is_none()
387        );
388    }
389}