1use std::borrow::Cow;
21use std::io::{Read as _, Seek as _, SeekFrom};
22use std::path::Path;
23use std::time::SystemTime;
24
25use anyhow::{Context, Result};
26use smallvec::SmallVec;
27
28use crate::save_patterns::{CaptureSummary, PatternSaveTracker, PrefixSaveRule};
29use crate::traits::{DetectedSave, SaveTracker};
30
31const MAGIC_SKYRIM_SE: &[u8] = b"TESV_SAVEGAME"; const MAGIC_FALLOUT4: &[u8] = b"FO4_SAVEGAME"; const MAGIC_FALLOUT76: &[u8] = b"FO76_SAVEGAME"; pub struct BethesdaSaveTracker {
40 magic: &'static [u8],
42 is_fo76: bool,
44}
45
46pub static SKYRIM_SAVE_TRACKER: BethesdaSaveTracker = BethesdaSaveTracker {
47 magic: MAGIC_SKYRIM_SE,
48 is_fo76: false,
49};
50
51pub static FALLOUT4_SAVE_TRACKER: BethesdaSaveTracker = BethesdaSaveTracker {
52 magic: MAGIC_FALLOUT4,
53 is_fo76: false,
54};
55
56pub static FALLOUT76_SAVE_TRACKER: BethesdaSaveTracker = BethesdaSaveTracker {
57 magic: MAGIC_FALLOUT76,
58 is_fo76: true,
59};
60
61const STARFIELD_SAVE_PREFIXES: &[PrefixSaveRule] = &[
62 PrefixSaveRule {
63 prefix: "Autosave",
64 category: "auto",
65 },
66 PrefixSaveRule {
67 prefix: "Quicksave",
68 category: "quick",
69 },
70 PrefixSaveRule {
71 prefix: "Exitsave",
72 category: "exit",
73 },
74 PrefixSaveRule {
75 prefix: "Save",
76 category: "manual",
77 },
78];
79
80pub static STARFIELD_SAVE_TRACKER: PatternSaveTracker = PatternSaveTracker {
81 prefix_rules: STARFIELD_SAVE_PREFIXES,
82 file_extensions: &["sfs"],
83 default_category: "manual",
84 recursive: false,
85 exclude_patterns: &[],
86 label_extractor: starfield_save_label,
87 summary: CaptureSummary::ByCategory,
88};
89
90impl SaveTracker for BethesdaSaveTracker {
93 fn save_patterns(&self) -> SmallVec<[String; 2]> {
94 smallvec::smallvec!["*.ess".into(), "*.bak".into()]
95 }
96
97 fn exclude_patterns(&self) -> SmallVec<[String; 2]> {
98 smallvec::smallvec!["*.skse".into()]
100 }
101
102 fn detect_saves(&self, save_dir: &Path) -> Result<Vec<DetectedSave>> {
103 let mut saves = Vec::new();
104
105 if !save_dir.exists() {
106 return Ok(saves);
107 }
108
109 for entry in std::fs::read_dir(save_dir)
110 .with_context(|| format!("failed to read directory: {}", save_dir.display()))?
111 {
112 let entry = entry?;
113 let path = entry.path();
114
115 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
117 if ext.eq_ignore_ascii_case("bak") {
118 continue;
119 }
120 if !ext.eq_ignore_ascii_case("ess") {
121 continue;
122 }
123
124 let modified = entry
125 .metadata()
126 .and_then(|m| m.modified())
127 .unwrap_or(SystemTime::UNIX_EPOCH);
128 let stem = path
129 .file_stem()
130 .and_then(|s| s.to_str())
131 .unwrap_or_default();
132 let category = classify_slot_name(stem);
133
134 let header = if let Ok(h) = read_save_header(&path, self.magic) {
136 h
137 } else {
138 let rel = path
140 .file_name()
141 .map_or_else(|| path.clone(), std::path::PathBuf::from);
142 saves.push(DetectedSave {
143 rel_path: rel,
144 category,
145 label: None,
146 modified,
147 });
148 continue;
149 };
150
151 let label = Some(format!(
152 "{} — Save {}",
153 header.player_name, header.save_number
154 ));
155 let rel = path
156 .file_name()
157 .map_or_else(|| path.clone(), std::path::PathBuf::from);
158
159 saves.push(DetectedSave {
160 rel_path: rel,
161 category,
162 label,
163 modified,
164 });
165 }
166
167 saves.sort_by(|a, b| b.modified.cmp(&a.modified));
169 Ok(saves)
170 }
171
172 fn describe_capture(&self, saves: &[DetectedSave]) -> String {
173 let prefix = if self.is_fo76 {
174 "capture (FO76 cache — server saves not tracked)"
175 } else {
176 "capture"
177 };
178
179 match saves.len() {
180 0 => format!("{prefix}: no new saves"),
181 1 => {
182 let s = &saves[0];
183 let name = s
184 .label
185 .as_deref()
186 .unwrap_or_else(|| s.rel_path.to_str().unwrap_or("unknown"));
187 format!("{prefix}: {} [{}]", name, s.category)
188 }
189 _ => {
190 let mut chars: std::collections::BTreeMap<String, Vec<u32>> =
192 std::collections::BTreeMap::new();
193 for s in saves {
194 let (char_name, slot_num) = parse_label(s.label.as_deref());
195 chars.entry(char_name).or_default().push(slot_num);
196 }
197
198 let parts: Vec<String> = chars
199 .iter()
200 .map(|(name, slots)| {
201 if slots.len() == 1 {
202 format!("{name} (slot {})", slots[0])
203 } else {
204 let mut sorted = slots.clone();
205 sorted.sort_unstable();
206 let slot_list: Vec<_> = sorted
207 .iter()
208 .map(std::string::ToString::to_string)
209 .collect();
210 format!("{name} (slots {})", slot_list.join(", "))
211 }
212 })
213 .collect();
214
215 format!("{prefix}: {} saves — {}", saves.len(), parts.join("; "))
216 }
217 }
218 }
219}
220
221struct SaveHeader {
224 save_number: u32,
225 player_name: String,
226}
227
228fn read_save_header(path: &Path, expected_magic: &[u8]) -> anyhow::Result<SaveHeader> {
233 let mut file =
234 std::fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
235
236 let mut magic_buf = vec![0u8; expected_magic.len()];
238 file.read_exact(&mut magic_buf)?;
239 if magic_buf != expected_magic {
240 anyhow::bail!("magic mismatch");
241 }
242
243 file.seek(SeekFrom::Current(4))?;
245
246 let mut num_buf = [0u8; 4];
248 file.read_exact(&mut num_buf)?;
249 let save_number = u32::from_le_bytes(num_buf);
250
251 let mut len_buf = [0u8; 2];
253 file.read_exact(&mut len_buf)?;
254 let name_len = u16::from_le_bytes(len_buf) as usize;
255
256 if name_len > 256 {
257 anyhow::bail!("implausibly long player name ({name_len} bytes)");
258 }
259
260 let mut name_buf = vec![0u8; name_len];
261 file.read_exact(&mut name_buf)?;
262 let player_name = String::from_utf8_lossy(&name_buf).into_owned();
263
264 Ok(SaveHeader {
265 save_number,
266 player_name,
267 })
268}
269
270fn classify_slot_name(stem: &str) -> Cow<'static, str> {
279 let lower = stem.to_lowercase();
280 if lower.starts_with("autosave") {
281 Cow::Borrowed("auto")
282 } else if lower.starts_with("quicksave") {
283 Cow::Borrowed("quick")
284 } else {
285 Cow::Borrowed("manual")
286 }
287}
288
289fn parse_label(label: Option<&str>) -> (String, u32) {
292 let Some(label) = label else {
293 return ("Unknown".to_string(), 0);
294 };
295 if let Some((name_part, slot_part)) = label.split_once(" — Save ") {
296 if let Ok(slot) = slot_part.parse() {
297 return (name_part.to_string(), slot);
298 }
299 tracing::warn!(
300 raw_slot = slot_part,
301 label,
302 "bethesda saves: failed to parse save slot number; treating as 0"
303 );
304 return (format!("{name_part} — Save {slot_part}"), 0);
305 }
306 (label.to_string(), 0)
307}
308
309fn starfield_save_label(path: &Path, rel_name: &str) -> Option<String> {
310 path.file_stem()
311 .and_then(|stem| stem.to_str())
312 .map(std::string::ToString::to_string)
313 .or_else(|| Some(rel_name.to_string()))
314}
315
316#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
325 fn classify_autosave() {
326 assert_eq!(classify_slot_name("Autosave1"), "auto");
327 assert_eq!(classify_slot_name("autosave"), "auto");
328 }
329
330 #[test]
331 fn classify_quicksave() {
332 assert_eq!(classify_slot_name("Quicksave"), "quick");
333 assert_eq!(classify_slot_name("Quicksave5"), "quick");
334 assert_eq!(classify_slot_name("quicksave1"), "quick");
335 }
336
337 #[test]
338 fn classify_manual_save() {
339 assert_eq!(
340 classify_slot_name("Save1_DEADBEEF_Lydia_WhiterunWorld"),
341 "manual"
342 );
343 assert_eq!(classify_slot_name("Save42"), "manual");
344 }
345
346 #[test]
349 fn parse_label_valid() {
350 let (name, slot) = parse_label(Some("Lydia — Save 14"));
351 assert_eq!(name, "Lydia");
352 assert_eq!(slot, 14);
353 }
354
355 #[test]
356 fn parse_label_no_label() {
357 let (name, slot) = parse_label(None);
358 assert_eq!(name, "Unknown");
359 assert_eq!(slot, 0);
360 }
361
362 #[test]
363 fn parse_label_no_separator() {
364 let (name, slot) = parse_label(Some("Just a name"));
365 assert_eq!(name, "Just a name");
366 assert_eq!(slot, 0);
367 }
368
369 #[test]
370 fn parse_label_unparseable_slot_preserves_raw_text() {
371 let (name, slot) = parse_label(Some("Lydia — Save abc"));
372 assert_eq!(name, "Lydia — Save abc");
373 assert_eq!(slot, 0);
374 }
375
376 #[test]
377 fn parse_label_empty_slot_preserves_raw_text() {
378 let (name, slot) = parse_label(Some("Lydia — Save "));
379 assert_eq!(name, "Lydia — Save ");
380 assert_eq!(slot, 0);
381 }
382
383 fn make_ess(magic: &[u8], save_number: u32, player_name: &str) -> Vec<u8> {
387 let mut buf = Vec::new();
388 buf.extend_from_slice(magic); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&save_number.to_le_bytes()); let name_bytes = player_name.as_bytes();
392 buf.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes()); buf.extend_from_slice(name_bytes); buf
395 }
396
397 #[test]
398 fn read_header_skyrim_se() {
399 let tmp = tempfile::tempdir().unwrap();
400 let path = tmp.path().join("Save1.ess");
401 std::fs::write(&path, make_ess(MAGIC_SKYRIM_SE, 42, "Lydia")).unwrap();
402
403 let hdr = read_save_header(&path, MAGIC_SKYRIM_SE).unwrap();
404 assert_eq!(hdr.save_number, 42);
405 assert_eq!(hdr.player_name, "Lydia");
406 }
407
408 #[test]
409 fn read_header_fallout4() {
410 let tmp = tempfile::tempdir().unwrap();
411 let path = tmp.path().join("Save1.ess");
412 std::fs::write(&path, make_ess(MAGIC_FALLOUT4, 7, "Sole Survivor")).unwrap();
413
414 let hdr = read_save_header(&path, MAGIC_FALLOUT4).unwrap();
415 assert_eq!(hdr.save_number, 7);
416 assert_eq!(hdr.player_name, "Sole Survivor");
417 }
418
419 #[test]
420 fn read_header_wrong_magic_returns_error() {
421 let tmp = tempfile::tempdir().unwrap();
422 let path = tmp.path().join("Save1.ess");
423 std::fs::write(&path, make_ess(MAGIC_FALLOUT4, 1, "X")).unwrap();
424
425 assert!(read_save_header(&path, MAGIC_SKYRIM_SE).is_err());
427 }
428
429 #[test]
430 fn read_header_empty_player_name() {
431 let tmp = tempfile::tempdir().unwrap();
432 let path = tmp.path().join("Save1.ess");
433 std::fs::write(&path, make_ess(MAGIC_SKYRIM_SE, 1, "")).unwrap();
434
435 let hdr = read_save_header(&path, MAGIC_SKYRIM_SE).unwrap();
436 assert_eq!(hdr.player_name, "");
437 }
438
439 #[test]
440 fn read_header_nonexistent_file() {
441 let result = read_save_header(
442 std::path::Path::new("/nonexistent/save.ess"),
443 MAGIC_SKYRIM_SE,
444 );
445 assert!(result.is_err());
446 }
447
448 #[test]
451 fn detect_saves_empty_dir() {
452 let tmp = tempfile::tempdir().unwrap();
453 let saves = SKYRIM_SAVE_TRACKER.detect_saves(tmp.path()).unwrap();
454 assert!(saves.is_empty());
455 }
456
457 #[test]
458 fn detect_saves_nonexistent_dir() {
459 let saves = SKYRIM_SAVE_TRACKER
460 .detect_saves(std::path::Path::new("/nonexistent/saves"))
461 .unwrap();
462 assert!(saves.is_empty());
463 }
464
465 #[test]
466 fn detect_saves_finds_ess_files() {
467 let tmp = tempfile::tempdir().unwrap();
468 let ess = tmp.path().join("Save1.ess");
469 std::fs::write(&ess, make_ess(MAGIC_SKYRIM_SE, 1, "Dragonborn")).unwrap();
470
471 let saves = SKYRIM_SAVE_TRACKER.detect_saves(tmp.path()).unwrap();
472 assert_eq!(saves.len(), 1);
473 assert!(
474 saves[0]
475 .label
476 .as_deref()
477 .unwrap_or("")
478 .contains("Dragonborn")
479 );
480 }
481
482 #[test]
483 fn detect_saves_skips_bak_files() {
484 let tmp = tempfile::tempdir().unwrap();
485 std::fs::write(tmp.path().join("Save1.bak"), b"ignored").unwrap();
486 std::fs::write(
487 tmp.path().join("Save1.ess"),
488 make_ess(MAGIC_SKYRIM_SE, 1, "Hero"),
489 )
490 .unwrap();
491
492 let saves = SKYRIM_SAVE_TRACKER.detect_saves(tmp.path()).unwrap();
493 assert_eq!(saves.len(), 1);
495 }
496
497 #[test]
498 fn detect_saves_wrong_game_magic_still_captured() {
499 let tmp = tempfile::tempdir().unwrap();
500 let ess = tmp.path().join("Save1.ess");
502 std::fs::write(&ess, make_ess(MAGIC_FALLOUT4, 5, "Sole")).unwrap();
503
504 let saves = SKYRIM_SAVE_TRACKER.detect_saves(tmp.path()).unwrap();
505 assert_eq!(
506 saves.len(),
507 1,
508 "file should be captured even if magic mismatches"
509 );
510 assert!(
511 saves[0].label.is_none(),
512 "label should be None when header fails"
513 );
514 }
515
516 #[test]
517 fn detect_saves_sorted_newest_first() {
518 let tmp = tempfile::tempdir().unwrap();
519 for i in 1..=3 {
520 let ess = tmp.path().join(format!("Save{i}.ess"));
521 std::fs::write(&ess, make_ess(MAGIC_SKYRIM_SE, i, "X")).unwrap();
522 std::thread::sleep(std::time::Duration::from_millis(5));
524 }
525
526 let saves = SKYRIM_SAVE_TRACKER.detect_saves(tmp.path()).unwrap();
527 assert_eq!(saves.len(), 3);
528 for i in 0..saves.len() - 1 {
530 assert!(saves[i].modified >= saves[i + 1].modified);
531 }
532 }
533
534 #[test]
537 fn describe_capture_no_saves() {
538 let msg = SKYRIM_SAVE_TRACKER.describe_capture(&[]);
539 assert!(msg.contains("no new saves"));
540 }
541
542 #[test]
543 fn describe_capture_single_save() {
544 let save = DetectedSave {
545 rel_path: "Save1.ess".into(),
546 category: Cow::Borrowed("manual"),
547 label: Some("Lydia — Save 14".to_string()),
548 modified: SystemTime::UNIX_EPOCH,
549 };
550 let msg = SKYRIM_SAVE_TRACKER.describe_capture(std::slice::from_ref(&save));
551 assert!(msg.contains("Lydia — Save 14"));
552 assert!(msg.contains("manual"));
553 }
554
555 #[test]
556 fn describe_capture_multiple_saves_groups_by_character() {
557 let saves: Vec<DetectedSave> = vec![
558 DetectedSave {
559 rel_path: "Save1.ess".into(),
560 category: Cow::Borrowed("manual"),
561 label: Some("Lydia — Save 14".to_string()),
562 modified: SystemTime::UNIX_EPOCH,
563 },
564 DetectedSave {
565 rel_path: "Save2.ess".into(),
566 category: Cow::Borrowed("manual"),
567 label: Some("Lydia — Save 15".to_string()),
568 modified: SystemTime::UNIX_EPOCH,
569 },
570 DetectedSave {
571 rel_path: "Save3.ess".into(),
572 category: Cow::Borrowed("manual"),
573 label: Some("Orc Mage — Save 7".to_string()),
574 modified: SystemTime::UNIX_EPOCH,
575 },
576 ];
577
578 let msg = SKYRIM_SAVE_TRACKER.describe_capture(&saves);
579 assert!(msg.contains("3 saves"));
580 assert!(msg.contains("Lydia"));
581 assert!(msg.contains("Orc Mage"));
582 }
583
584 #[test]
585 fn describe_capture_fo76_includes_warning() {
586 let msg = FALLOUT76_SAVE_TRACKER.describe_capture(&[]);
587 assert!(msg.contains("FO76"));
588 }
589}