1use std::fs;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone)]
12pub struct DiscoveredFolder {
13 pub label: String,
14 pub path: PathBuf,
15}
16
17const 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"), ("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
42pub 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 #[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 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 #[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 scan_other_users(&mut found, &mut seen);
143
144 scan_common_install_dirs(&mut found, &mut seen);
146
147 found
148}
149
150fn 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#[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#[cfg(not(target_os = "windows"))]
197fn scan_other_users(
198 found: &mut Vec<DiscoveredFolder>,
199 seen: &mut std::collections::HashSet<PathBuf>,
200) {
201 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 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 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#[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#[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 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
299fn 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 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#[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
360pub fn quick_discover() -> Option<PathBuf> {
373 #[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 #[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
409pub 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
430pub fn derive_ini_path(save_path: &Path) -> Option<PathBuf> {
434 let mut current = save_path.to_path_buf();
435
436 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 let save = Path::new("C:/Users/test/AppData/Local/Subnautica2/Saved/SaveGames");
457 let result = derive_ini_path(save);
459 let _ = result;
461 }
462}