Skip to main content

notalterra/
discovery.rs

1//! Save-folder discovery.
2//!
3//! Checks the current user's default save locations — no scanning of other
4//! profiles or system drives. Run at startup as a convenience; if nothing is
5//! found the user enters a path manually via `Set save folder`.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// A discovered save folder.
11#[derive(Debug, Clone)]
12pub struct DiscoveredFolder {
13    pub label: String,
14    pub path: PathBuf,
15}
16
17/// Known save-root patterns (relative from a user profile or base directory).
18///
19/// The same patterns work on both platforms because UE5 keeps the same
20/// directory layout regardless of OS.
21const KNOWN_PATTERNS: &[(&str, &str)] = &[
22    (
23        "Steam (LocalLow)",
24        "AppData/LocalLow/Unknown Worlds/Subnautica2",
25    ),
26    (
27        "Steam (LocalLow, alt)",
28        "AppData/LocalLow/Unknown Worlds/Subnautica 2",
29    ),
30    ("AppData Local", "AppData/Local/Subnautica2/Saved/SaveGames"),
31    (
32        "AppData Local (alt)",
33        "AppData/Local/Subnautica 2/Saved/SaveGames",
34    ),
35    ("Xbox / Game Pass", "AppData/Local/Packages"), // partial — needs wildcard below
36    ("Saved Games", "Saved Games/Subnautica2"),
37    ("Saved Games (alt)", "Saved Games/Subnautica 2"),
38    ("Documents", "Documents/Subnautica2"),
39    ("Epic / Steam custom", "AppData/LocalLow/Subnautica2"),
40];
41
42/// Search all known locations for Subnautica 2 save folders.
43///
44/// Returns a deduplicated, ranked list.  The first result is cached as
45/// `save_path` in config.ini so subsequent launches skip the scan.
46/// Auto-discover Subnautica 2 save folders across Steam, Xbox, Epic, and
47/// custom installs.  Checks both modern and legacy Steam paths.
48///
49/// Returns a deduplicated, ranked list.  The first result is cached as
50/// `save_path` in config.ini so subsequent launches skip the scan.
51pub fn discover_save_folders() -> Vec<DiscoveredFolder> {
52    let mut found: Vec<DiscoveredFolder> = Vec::new();
53    let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
54
55    // 0. Fast-path: %LOCALAPPDATA%\Subnautica2\Saved\SaveGames (primary)
56    #[cfg(target_os = "windows")]
57    {
58        if let Some(local) = dirs::data_local_dir() {
59            let primary = local.join("Subnautica2").join("Saved").join("SaveGames");
60            if primary.exists() && has_save_files(&primary) {
61                found.push(DiscoveredFolder {
62                    label: "AppData Local".into(),
63                    path: primary,
64                });
65                return found;
66            }
67        }
68    }
69    #[cfg(not(target_os = "windows"))]
70    {
71        if let Some(home) = dirs::home_dir() {
72            let proton_paths = &[
73                ".steam/steam/steamapps/compatdata/1962700/pfx/drive_c/users/steamuser/AppData/Local/Subnautica2/Saved/SaveGames",
74                ".local/share/Steam/steamapps/compatdata/1962700/pfx/drive_c/users/steamuser/AppData/Local/Subnautica2/Saved/SaveGames",
75            ];
76            for rel in proton_paths {
77                let candidate = home.join(rel);
78                if candidate.exists() && has_save_files(&candidate) {
79                    found.push(DiscoveredFolder {
80                        label: "Proton / Steam Deck".into(),
81                        path: candidate,
82                    });
83                    return found;
84                }
85            }
86        }
87        if let Some(data) = dirs::data_local_dir() {
88            let primary = data.join("Subnautica2").join("Saved").join("SaveGames");
89            if primary.exists() && has_save_files(&primary) {
90                found.push(DiscoveredFolder {
91                    label: "XDG Data".into(),
92                    path: primary,
93                });
94                return found;
95            }
96        }
97    }
98
99    // 1. Current user profile — remaining patterns
100    if let Some(home) = dirs::home_dir() {
101        for (label, rel) in KNOWN_PATTERNS {
102            let candidate = home.join(rel);
103            if candidate.exists()
104                && candidate.is_dir()
105                && has_save_files(&candidate)
106                && seen.insert(candidate.clone())
107            {
108                found.push(DiscoveredFolder {
109                    label: label.to_string(),
110                    path: candidate,
111                });
112            }
113        }
114    }
115
116    // 2. Xbox / Game Pass wildcard scan
117    #[cfg(target_os = "windows")]
118    {
119        if let Some(home) = dirs::home_dir() {
120            let pkg_root = home.join("AppData/Local/Packages");
121            if pkg_root.exists() {
122                if let Ok(entries) = fs::read_dir(&pkg_root) {
123                    for entry in entries.flatten() {
124                        let name = entry.file_name();
125                        let name_str = name.to_string_lossy();
126                        if name_str.contains("Subnautica2") {
127                            let wgs = entry.path().join("SystemAppData/wgs");
128                            if wgs.exists() && has_save_files(&wgs) && seen.insert(wgs.clone()) {
129                                found.push(DiscoveredFolder {
130                                    label: "Xbox / Game Pass".into(),
131                                    path: wgs,
132                                });
133                            }
134                        }
135                    }
136                }
137            }
138        }
139    }
140
141    // 3. Fallback: scan other user profiles
142    scan_other_users(&mut found, &mut seen);
143
144    // 4. Broad custom-install scan
145    scan_common_install_dirs(&mut found, &mut seen);
146
147    found
148}
149
150/// Check if a directory contains at least one `.sav` or `.save` file.
151fn has_save_files(dir: &Path) -> bool {
152    let check = |ext: &str| -> bool {
153        fs::read_dir(dir)
154            .map(|entries| {
155                entries
156                    .flatten()
157                    .any(|e| e.file_name().to_string_lossy().ends_with(ext))
158            })
159            .unwrap_or(false)
160    };
161    check(".sav") || check(".save")
162}
163
164/// Scan other user profiles on the system.
165#[cfg(target_os = "windows")]
166fn scan_other_users(
167    found: &mut Vec<DiscoveredFolder>,
168    seen: &mut std::collections::HashSet<PathBuf>,
169) {
170    for drive in fixed_drives() {
171        let users = Path::new(&drive).join("Users");
172        if !users.exists() {
173            continue;
174        }
175        if let Ok(entries) = fs::read_dir(&users) {
176            for user_dir in entries.flatten() {
177                let user_path = user_dir.path();
178                for (label, rel) in KNOWN_PATTERNS {
179                    let candidate = user_path.join(rel);
180                    if candidate.exists()
181                        && has_save_files(&candidate)
182                        && seen.insert(candidate.clone())
183                    {
184                        found.push(DiscoveredFolder {
185                            label: label.to_string(),
186                            path: candidate,
187                        });
188                    }
189                }
190            }
191        }
192    }
193}
194
195/// Linux variant — same logic as the Windows version above.
196#[cfg(not(target_os = "windows"))]
197fn scan_other_users(
198    found: &mut Vec<DiscoveredFolder>,
199    seen: &mut std::collections::HashSet<PathBuf>,
200) {
201    // On Linux, check /home for other users
202    let home_root = Path::new("/home");
203    if !home_root.exists() {
204        return;
205    }
206    if let Ok(entries) = fs::read_dir(home_root) {
207        for user_dir in entries.flatten() {
208            let user_path = user_dir.path();
209            for (label, rel) in KNOWN_PATTERNS {
210                let candidate = user_path.join(rel);
211                if candidate.exists()
212                    && has_save_files(&candidate)
213                    && seen.insert(candidate.clone())
214                {
215                    found.push(DiscoveredFolder {
216                        label: label.to_string(),
217                        path: candidate,
218                    });
219                }
220            }
221        }
222    }
223    // Also check common Steam Deck paths
224    let deck_paths = &[
225        Path::new("/run/media/mmcblk0p1/steamapps/compatdata"),
226        Path::new("/home/deck/.local/share/Steam/steamapps/compatdata"),
227    ];
228    for base in deck_paths {
229        if base.exists() {
230            // Walk compatdata for Subnautica 2 prefix
231            if let Ok(entries) = fs::read_dir(base) {
232                for app_entry in entries.flatten() {
233                    let pfx = app_entry.path().join("pfx/drive_c/users/steamuser");
234                    for (label, rel) in KNOWN_PATTERNS {
235                        let candidate = pfx.join(rel);
236                        if candidate.exists()
237                            && has_save_files(&candidate)
238                            && seen.insert(candidate.clone())
239                        {
240                            found.push(DiscoveredFolder {
241                                label: format!("Steam Deck — {label}"),
242                                path: candidate,
243                            });
244                        }
245                    }
246                }
247            }
248        }
249    }
250}
251
252/// Scan common install directories (Program Files, Games, Steam, etc.).
253#[cfg(target_os = "windows")]
254fn scan_common_install_dirs(
255    found: &mut Vec<DiscoveredFolder>,
256    seen: &mut std::collections::HashSet<PathBuf>,
257) {
258    for drive in fixed_drives() {
259        let roots: &[&str] = &[
260            &format!("{drive}Games"),
261            &format!("{drive}Program Files"),
262            &format!("{drive}Program Files (x86)"),
263            &format!("{drive}Steam"),
264            &format!("{drive}Epic Games"),
265        ];
266        for rt in roots {
267            let p = Path::new(rt);
268            if !p.exists() {
269                continue;
270            }
271            walk_for_subnautica(p, "custom install", found, seen);
272        }
273    }
274}
275
276/// Linux variant — same logic as the Windows version above.
277#[cfg(not(target_os = "windows"))]
278fn scan_common_install_dirs(
279    found: &mut Vec<DiscoveredFolder>,
280    seen: &mut std::collections::HashSet<PathBuf>,
281) {
282    let roots: &[&str] = &["/opt", "/usr/local/games", "/usr/share/games"];
283    for rt in roots {
284        let p = Path::new(rt);
285        if !p.exists() {
286            continue;
287        }
288        walk_for_subnautica(p, "custom install", found, seen);
289    }
290    // Steam library paths
291    if let Some(home) = dirs::home_dir() {
292        let steam = home.join(".local/share/Steam");
293        if steam.exists() {
294            walk_for_subnautica(&steam, "Steam", found, seen);
295        }
296    }
297}
298
299/// Recursively walk a root looking for folders named "*Subnautica*".
300fn walk_for_subnautica(
301    root: &Path,
302    label: &str,
303    found: &mut Vec<DiscoveredFolder>,
304    seen: &mut std::collections::HashSet<PathBuf>,
305) {
306    use std::collections::VecDeque;
307
308    let mut queue: VecDeque<PathBuf> = VecDeque::new();
309    queue.push_back(root.to_path_buf());
310
311    while let Some(dir) = queue.pop_front() {
312        // Limit depth: don't recurse more than 5 levels from root
313        let depth = dir
314            .components()
315            .count()
316            .saturating_sub(root.components().count());
317        if depth > 5 {
318            continue;
319        }
320
321        let entries = match fs::read_dir(&dir) {
322            Ok(e) => e,
323            Err(_) => continue,
324        };
325
326        for entry in entries.flatten() {
327            let path = entry.path();
328            let name = path
329                .file_name()
330                .map(|n| n.to_string_lossy().to_lowercase())
331                .unwrap_or_default();
332
333            if name.contains("subnautica") && has_save_files(&path) && seen.insert(path.clone()) {
334                found.push(DiscoveredFolder {
335                    label: label.to_string(),
336                    path: path.clone(),
337                });
338            }
339
340            if path.is_dir() {
341                queue.push_back(path);
342            }
343        }
344    }
345}
346
347/// List available fixed drives on Windows.
348#[cfg(target_os = "windows")]
349fn fixed_drives() -> Vec<String> {
350    let mut drives = Vec::new();
351    for letter in b'A'..=b'Z' {
352        let path = format!("{}:\\", letter as char);
353        if Path::new(&path).exists() {
354            drives.push(path);
355        }
356    }
357    drives
358}
359
360/// Quick check of the current user's default save locations.
361///
362/// No scanning of other profiles, no recursion into system directories.
363/// Returns the first valid path found, or `None`.
364///
365/// **Windows** (checked in order):
366/// 1. `%LOCALAPPDATA%\Subnautica2\Saved\SaveGames`
367///
368/// **Linux / Steam Deck** (checked in order):
369/// 1. `~/.steam/steam/steamapps/compatdata/1962700/pfx/drive_c/users/steamuser/AppData/Local/Subnautica2/Saved/SaveGames`
370/// 2. `~/.local/share/Steam/steamapps/compatdata/1962700/pfx/drive_c/users/steamuser/AppData/Local/Subnautica2/Saved/SaveGames`
371/// 3. `$XDG_DATA_HOME/Subnautica2/Saved/SaveGames` (typically `~/.local/share/Subnautica2/Saved/SaveGames`)
372pub fn quick_discover() -> Option<PathBuf> {
373    // Primary: %LOCALAPPDATA%\Subnautica2\Saved\SaveGames (Windows)
374    #[cfg(target_os = "windows")]
375    {
376        if let Some(local) = dirs::data_local_dir() {
377            let primary = local.join("Subnautica2").join("Saved").join("SaveGames");
378            if primary.exists() && has_save_files(&primary) {
379                return Some(primary);
380            }
381        }
382    }
383
384    // Primary: Proton paths + XDG data (Linux / Steam Deck)
385    #[cfg(not(target_os = "windows"))]
386    {
387        if let Some(home) = dirs::home_dir() {
388            for rel in &[
389                ".steam/steam/steamapps/compatdata/1962700/pfx/drive_c/users/steamuser/AppData/Local/Subnautica2/Saved/SaveGames",
390                ".local/share/Steam/steamapps/compatdata/1962700/pfx/drive_c/users/steamuser/AppData/Local/Subnautica2/Saved/SaveGames",
391            ] {
392                let candidate = home.join(rel);
393                if candidate.exists() && has_save_files(&candidate) {
394                    return Some(candidate);
395                }
396            }
397        }
398        if let Some(data) = dirs::data_local_dir() {
399            let primary = data.join("Subnautica2").join("Saved").join("SaveGames");
400            if primary.exists() && has_save_files(&primary) {
401                return Some(primary);
402            }
403        }
404    }
405
406    None
407}
408
409// ── helpers for the TUI ──────────────────────────────────────────────────
410
411/// Validate that a manually entered path exists and contains save files.
412pub fn validate_custom_path(input: &str) -> Option<PathBuf> {
413    let expanded = if input.starts_with('~') {
414        if let Some(home) = dirs::home_dir() {
415            input.replacen('~', &home.to_string_lossy(), 1)
416        } else {
417            input.to_string()
418        }
419    } else {
420        input.to_string()
421    };
422    let path = PathBuf::from(expanded);
423    if path.exists() && has_save_files(&path) {
424        Some(path)
425    } else {
426        None
427    }
428}
429
430/// Derive the Config\Windows path from a SaveGames path.
431///
432/// Walks up to the `Saved` ancestor, then down to `Config/Windows`.
433pub fn derive_ini_path(save_path: &Path) -> Option<PathBuf> {
434    let mut current = save_path.to_path_buf();
435
436    // Walk up looking for "Saved" component
437    loop {
438        if current.file_name().map(|n| n == "Saved").unwrap_or(false) {
439            let config = current.join("Config").join("Windows");
440            return if config.exists() { Some(config) } else { None };
441        }
442        if !current.pop() {
443            break;
444        }
445    }
446    None
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn test_derive_ini_path_sample() {
455        // Should resolve from a save path even if dirs don't exist locally
456        let save = Path::new("C:/Users/test/AppData/Local/Subnautica2/Saved/SaveGames");
457        // Since we can't test existence, at least verify the walk logic compiles
458        let result = derive_ini_path(save);
459        // On a test machine without the game, this returns None — which is fine
460        let _ = result;
461    }
462}