Skip to main content

source_fs/
fs.rs

1use std::{collections::HashMap, path::{Path, PathBuf}, sync::Arc};
2
3use crate::{FileSystemError, GameInfoProvider, PackFile, utils};
4
5#[derive(Debug, Clone, Default)]
6pub struct FileSystemOptions {
7    pub bin_platform: Option<String>,
8}
9
10/// Core FileSystem representation holding physical directories and loaded pack files.
11#[derive(Debug)]
12pub struct FileSystem<P: PackFile> {
13    root_path: PathBuf,
14    search_path_dirs: HashMap<String, Vec<PathBuf>>,
15    search_path_vpks: HashMap<String, Vec<Arc<P>>>,
16}
17
18impl<P: PackFile> Clone for FileSystem<P> {
19    fn clone(&self) -> Self {
20        Self {
21            root_path: self.root_path.clone(),
22            search_path_dirs: self.search_path_dirs.clone(),
23            search_path_vpks: self.search_path_vpks.clone(),
24        }
25    }
26}
27
28impl<P: PackFile> FileSystem<P> {
29    /// Locates the game using the local Steam installation and loads the filesystem.
30    #[cfg(feature = "steam")]
31    pub fn load_from_app_id<G: GameInfoProvider>(
32        app_id: u32,
33        game_name: &str,
34        options: &FileSystemOptions,
35    ) -> Result<Self, FileSystemError> {
36        let steamdir = steamlocate::SteamDir::locate().map_err(|_| FileSystemError::SteamNotFound)?;
37        let (app, library) = steamdir
38            .find_app(app_id)?
39            .ok_or(FileSystemError::SteamAppNotFound(app_id))?;
40        let game_path = library.resolve_app_dir(&app).join(&game_name);
41
42        Self::load_from_path::<G>(&game_path, options)
43    }
44
45    /// Loads the filesystem from a specific game directory (where `gameinfo.txt` resides).
46    pub fn load_from_path<G: GameInfoProvider>(
47        game_path: &Path,
48        options: &FileSystemOptions,
49    ) -> Result<Self, FileSystemError> {
50        let gameinfo_path = game_path.join("gameinfo.txt");
51        if !gameinfo_path.is_file() {
52            return Err(FileSystemError::GameInfoNotFound(gameinfo_path));
53        }
54
55        let root_path = game_path.parent()
56            .ok_or_else(|| FileSystemError::InvalidGamePath(game_path.to_path_buf()))?
57            .to_path_buf();
58        let game_id = game_path.file_name()
59            .ok_or_else(|| FileSystemError::InvalidGamePath(game_path.to_path_buf()))?
60            .to_string_lossy()
61            .to_string();
62
63        let mut fs = Self {
64            root_path,
65            search_path_dirs: HashMap::new(),
66            search_path_vpks: HashMap::new(),
67        };
68
69        let search_paths = G::get_search_paths(&gameinfo_path)
70            .ok_or(FileSystemError::GameInfoParseError)?;
71        if search_paths.is_empty() {
72            return Ok(fs);
73        }
74
75        for (i, (key, value)) in search_paths.into_iter().enumerate() {
76            let searches: Vec<String> = key.to_lowercase()
77                .split('+')
78                .map(|s| s.to_string())
79                .collect();
80
81            let mut path = value;// .to_lowercase(); // todo: case insensitive!
82
83            if path.ends_with('.') && !path.ends_with("..") {
84                path.pop();
85            }
86            let path = utils::normalize_slashes(&path, false, false);
87
88            if path.ends_with(".vpk") {
89                let mut full_path = fs.root_path.join(&path);
90
91                if !full_path.exists() {
92                    // Try to fallback to the `_dir.vpk` naming convention
93                    if let Some(stem) = full_path.file_stem() {
94                        let parent = full_path.parent().unwrap_or_else(|| Path::new(""));
95                        let dir_vpk = parent.join(format!("{}_dir.vpk", stem.to_string_lossy()));
96                        if dir_vpk.exists() {
97                            full_path = dir_vpk;
98                        } else {
99                            continue;
100                        }
101                    } else {
102                        continue;
103                    }
104                }
105
106                if let Some(pack) = P::open(&full_path).map(Arc::new) {
107                    for search in &searches {
108                        fs.search_path_vpks
109                            .entry(search.clone())
110                            .or_default()
111                            .push(Arc::clone(&pack));
112                    }
113                }
114            } else {
115                for search in &searches {
116                    if path.ends_with("/*") {
117                        let glob_parent_path = fs.root_path.join(&path[..path.len() - 2]);
118                        if glob_parent_path.is_dir() {
119                            if let Ok(entries) = std::fs::read_dir(&glob_parent_path) {
120                                for entry in entries.flatten() {
121                                    let glob_child_path = utils::normalize_slashes(
122                                        &entry.path().to_string_lossy(),
123                                        false,
124                                        false,
125                                    );
126                                    fs.search_path_dirs
127                                        .entry(search.clone())
128                                        .or_default()
129                                        .push(PathBuf::from(glob_child_path));
130                                }
131                            }
132                        }
133                    } else {
134                        let test_path = fs.root_path.join(&path);
135                        // dbg!(&fs.root_path, &path, &test_path); // todo: debug
136                        // dbg!(&test_path);
137                        if test_path.exists() {
138                            fs.search_path_dirs
139                                .entry(search.clone())
140                                .or_default()
141                                .push(PathBuf::from(&path));
142
143                            // Automatically populate `gamebin` and `mod` depending on context
144                            if search == "game" {
145                                fs.search_path_dirs
146                                    .entry("gamebin".to_string())
147                                    .or_default()
148                                    .push(PathBuf::from(format!("{}/bin", path)));
149
150                                if i == 0 {
151                                    fs.search_path_dirs
152                                        .entry("mod".to_string())
153                                        .or_default()
154                                        .push(PathBuf::from(&path));
155                                }
156                            }
157                        }
158                    }
159                }
160            }
161        }
162
163        // Setup default path overrides
164        let exec_paths = fs.search_path_dirs.entry("executable_path".to_string()).or_default();
165        if let Some(plat) = &options.bin_platform {
166            let plat_path = fs.root_path.join("bin").join(plat);
167            if plat_path.exists() {
168                exec_paths.push(PathBuf::from(format!("bin/{}", plat)));
169            }
170        }
171        exec_paths.push(PathBuf::from("bin"));
172        exec_paths.push(PathBuf::from(""));
173
174        fs.search_path_dirs
175            .entry("platform".to_string())
176            .or_insert_with(|| vec![PathBuf::from("platform")]);
177
178        if let Some(game_paths) = fs.search_path_dirs.get_mut("game") {
179            let platform_buf = PathBuf::from("platform");
180            if !game_paths.contains(&platform_buf) {
181                game_paths.push(platform_buf);
182            }
183        }
184
185        fs.search_path_dirs
186            .entry("default_write_path".to_string())
187            .or_insert_with(|| vec![PathBuf::from(&game_id)]);
188
189        fs.search_path_dirs
190            .entry("logdir".to_string())
191            .or_insert_with(|| vec![PathBuf::from(&game_id)]);
192
193        fs.search_path_dirs
194            .entry("config".to_string())
195            .or_insert_with(|| vec![PathBuf::from("platform/config")]);
196
197        Ok(fs)
198    }
199
200    pub fn root_path(&self) -> &PathBuf {
201        &self.root_path
202    }
203
204    pub fn search_path_dirs(&self) -> &HashMap<String, Vec<PathBuf>> {
205        &self.search_path_dirs
206    }
207
208    pub fn search_path_dirs_mut(&mut self) -> &mut HashMap<String, Vec<PathBuf>> {
209        &mut self.search_path_dirs
210    }
211
212    pub fn search_path_vpks(&self) -> &HashMap<String, Vec<Arc<P>>> {
213        &self.search_path_vpks
214    }
215
216    pub fn search_path_vpks_mut(&mut self) -> &mut HashMap<String, Vec<Arc<P>>> {
217        &mut self.search_path_vpks
218    }
219
220    /// Formats an asset path, safely preventing duplicated prefixes or suffixes.
221    fn format_asset_path(name: &str, prefix: &str, suffix: &str) -> String {
222        let mut path = String::with_capacity(name.len() + prefix.len() + suffix.len());
223        if !prefix.is_empty() && !name.starts_with(prefix) {
224            path.push_str(prefix);
225        }
226        path.push_str(name);
227        if !suffix.is_empty() && !name.ends_with(suffix) {
228            path.push_str(suffix);
229        }
230
231        path
232    }
233
234    pub fn find_file(&self, file_path: &str, search_path: &str) -> Option<PathBuf> {
235        let file_path_str = utils::normalize_slashes(&file_path.to_lowercase(), true, false);
236        let search_path_str = search_path.to_lowercase();
237
238        if let Some(dirs) = self.search_path_dirs.get(&search_path_str) {
239            for base_path in dirs {
240                let base_dir = self.root_path.join(base_path);
241                if let Some(resolved_path) = utils::resolve_path_case_insensitive(&base_dir, &file_path_str) {
242                    return Some(resolved_path);
243                }
244            }
245        }
246
247        None
248    }
249
250    /// Reads data from the internal mounted paths using standard Source Engine priorities.
251    pub fn read(&self, file_path: &str, search_path: &str, prioritize_vpks: bool) -> Option<Vec<u8>> {
252        let file_path_str = utils::normalize_slashes(&file_path.to_lowercase(), true, false);
253        let search_path_str = search_path.to_lowercase();
254
255        if prioritize_vpks {
256            if let Some(data) = self.check_vpks(&search_path_str, &file_path_str) {
257                return Some(data);
258            }
259        }
260
261        if let Some(resolved_path) = self.find_file(&file_path_str, &search_path_str) {
262            if let Ok(data) = std::fs::read(resolved_path) {
263                return Some(data);
264            }
265        }
266
267        if !prioritize_vpks {
268            return self.check_vpks(&search_path_str, &file_path_str);
269        }
270
271        None
272    }
273
274    /// Same as `read`, but takes an optional active map pack file which gets highest priority.
275    pub fn read_for_map(
276        &self,
277        map_pack: Option<&P>,
278        file_path: &str,
279        search_path: &str,
280        prioritize_vpks: bool,
281    ) -> Option<Vec<u8>> {
282        if let Some(map) = map_pack {
283            if map.has_entry(file_path) {
284                return map.read_entry(file_path);
285            }
286        }
287        self.read(file_path, search_path, prioritize_vpks)
288    }
289
290    pub fn read_str(&self, file_path: &str, search_path: &str, prioritize_vpks: bool) -> Option<String> {
291        let data = self.read(file_path, search_path, prioritize_vpks)?;
292        // Some(String::from_utf8_lossy(&data).to_string())
293        String::from_utf8(data).ok()
294    }
295
296    /// Finds an asset's PathBuf without reading its contents into memory.
297    pub fn find_asset(&self, name: &str, prefix: &str, suffix: &str, search_path: &str) -> Option<PathBuf> {
298        let path = Self::format_asset_path(name, prefix, suffix);
299        self.find_file(&path, search_path)
300    }
301
302    /// Reads any asset as raw bytes, safely appending prefix and suffix if missing.
303    pub fn read_asset(&self, name: &str, prefix: &str, suffix: &str, search_path: &str, prioritize_vpks: bool) -> Option<Vec<u8>> {
304        let path = Self::format_asset_path(name, prefix, suffix);
305        self.read(&path, search_path, prioritize_vpks)
306    }
307
308    /// Reads any asset as a UTF-8 string, safely appending prefix and suffix if missing.
309    pub fn read_asset_str(&self, name: &str, prefix: &str, suffix: &str, search_path: &str, prioritize_vpks: bool) -> Option<String> {
310        self.read_asset(name, prefix, suffix, search_path, prioritize_vpks)
311            .and_then(|data| String::from_utf8(data).ok())
312    }
313
314    /// Reads a material file (.vmt).
315    pub fn read_material(&self, name: &str, search_path: &str, prioritize_vpks: bool) -> Option<Vec<u8>> {
316        self.read_asset(name, "materials/", ".vmt", search_path, prioritize_vpks)
317    }
318
319    /// Reads a material file (.vmt) as a UTF-8 string.
320    pub fn read_material_str(&self, name: &str, search_path: &str, prioritize_vpks: bool) -> Option<String> {
321        self.read_asset_str(name, "materials/", ".vmt", search_path, prioritize_vpks)
322    }
323
324    /// Reads a model file (.mdl).
325    pub fn read_model(&self, name: &str, search_path: &str, prioritize_vpks: bool) -> Option<Vec<u8>> {
326        self.read_asset(name, "models/", ".mdl", search_path, prioritize_vpks)
327    }
328
329    /// Reads a model file (.mdl) as a UTF-8 string.
330    pub fn read_model_str(&self, name: &str, search_path: &str, prioritize_vpks: bool) -> Option<String> {
331        self.read_asset_str(name, "models/", ".mdl", search_path, prioritize_vpks)
332    }
333
334    /// Reads a sound file (.wav or fallback to .mp3) as a UTF-8 string.
335    pub fn read_sound_str(&self, name: &str, search_path: &str, prioritize_vpks: bool) -> Option<String> {
336        // Try exact name or append .wav
337        if let Some(data) = self.read_asset_str(name, "sound/", ".wav", search_path, prioritize_vpks) {
338            return Some(data);
339        }
340
341        // Fallback to .mp3
342        let clean_name = name.strip_suffix(".wav").unwrap_or(name);
343        self.read_asset_str(clean_name, "sound/", ".mp3", search_path, prioritize_vpks)
344    }
345
346    fn find_in_vpks(&self, search_path: &str, file_path: &str) -> Option<PathBuf> {
347        if let Some(vpks) = self.search_path_vpks.get(search_path) {
348            for vpk in vpks {
349                if vpk.has_entry(file_path) {
350                    return Some(PathBuf::from(file_path));
351                }
352            }
353        }
354        None
355    }
356
357    fn check_vpks(&self, search_path: &str, file_path: &str) -> Option<Vec<u8>> {
358        if let Some(vpks) = self.search_path_vpks.get(search_path) {
359            for vpk in vpks {
360                if vpk.has_entry(file_path) {
361                    return vpk.read_entry(file_path);
362                }
363            }
364        }
365        None
366    }
367}