Skip to main content

modde_games/bethesda/
saves.rs

1//! Bethesda game save detection and classification.
2//!
3//! Covers Skyrim SE/AE, Fallout 4, Fallout 76 (partially), and Starfield.
4//!
5//! # Binary header format
6//!
7//! All Bethesda save files share this header layout:
8//! ```text
9//! Offset   Size  Field
10//! 0        var   Magic string (see MAGIC_* constants below)
11//! +0       4     headerSize  (u32 LE)
12//! +4       4     saveNumber  (u32 LE)  — unique slot ID, increments each save
13//! +8       2     nameLength  (u16 LE)
14//! +10      var   playerName  (UTF-8, `nameLength` bytes)
15//! ```
16//!
17//! Fallout 76 saves are server-side; the local `.sav` files are partial cache
18//! entries. We capture them but label the commit with a clear warning.
19
20use 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
31// ── Magic constants ───────────────────────────────────────────────────────────
32
33const MAGIC_SKYRIM_SE: &[u8] = b"TESV_SAVEGAME"; // 13 bytes
34const MAGIC_FALLOUT4: &[u8] = b"FO4_SAVEGAME"; // 12 bytes
35const MAGIC_FALLOUT76: &[u8] = b"FO76_SAVEGAME"; // 13 bytes
36
37// ── Public singletons ─────────────────────────────────────────────────────────
38
39pub struct BethesdaSaveTracker {
40    /// Magic bytes prefix that identifies save files for this game.
41    magic: &'static [u8],
42    /// Fallout 76 warning (server-side saves).
43    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
90// ── SaveTracker impl ──────────────────────────────────────────────────────────
91
92impl 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        // Skyrim stores a global "Skse" co-save alongside .ess; we don't track it as a save
99        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            // Only process .ess files (skip .bak — they're backup copies)
116            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            // Parse header to extract save number and character name
135            let header = if let Ok(h) = read_save_header(&path, self.magic) {
136                h
137            } else {
138                // Unreadable or wrong-game save — include without metadata
139                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        // Newest first
168        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                // Group by character name (extracted from label before " — Save N")
191                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
221// ── Binary header parser ──────────────────────────────────────────────────────
222
223struct SaveHeader {
224    save_number: u32,
225    player_name: String,
226}
227
228/// Read the binary save header to extract save number and player name.
229///
230/// Returns `Err` if the file is unreadable, too short, or doesn't start
231/// with the expected magic bytes (wrong game).
232fn 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    // Read and verify magic
237    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    // Skip headerSize (4 bytes)
244    file.seek(SeekFrom::Current(4))?;
245
246    // Read saveNumber (4 bytes, LE)
247    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    // Read playerName: u16 length prefix + data
252    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
270// ── Helpers ───────────────────────────────────────────────────────────────────
271
272/// Classify the save slot type from the filename stem.
273///
274/// Skyrim SE save filenames follow patterns like:
275/// - `Save1_XXXXXXXX_PlayerName_cell_hhmm_dd.dd.ddd.ess`  → "manual"
276/// - `Autosave1.ess` → "auto"
277/// - `Quicksave.ess` or `Quicksave1.ess` → "quick"
278fn 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
289/// Parse a save label like `"Lydia — Save 14"` into `("Lydia", 14)`.
290/// Falls back to `("Unknown", 0)` if the format doesn't match.
291fn 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// ── Tests ─────────────────────────────────────────────────────────────────────
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    // ── classify_slot_name ───────────────────────────────────────────
323
324    #[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    // ── parse_label ──────────────────────────────────────────────────
347
348    #[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    // ── read_save_header ─────────────────────────────────────────────
384
385    /// Build a minimal .ess file in memory.
386    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); // magic
389        buf.extend_from_slice(&0u32.to_le_bytes()); // headerSize (ignored)
390        buf.extend_from_slice(&save_number.to_le_bytes()); // saveNumber
391        let name_bytes = player_name.as_bytes();
392        buf.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes()); // nameLength
393        buf.extend_from_slice(name_bytes); // playerName
394        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        // Trying to read it as Skyrim should fail
426        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    // ── detect_saves ─────────────────────────────────────────────────
449
450    #[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        // .bak must be skipped; only .ess counts
494        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        // FO4 save read by Skyrim tracker — header parse will fail, but file is still captured
501        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            // Touch files with slightly different mtimes
523            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        // Newest first
529        for i in 0..saves.len() - 1 {
530            assert!(saves[i].modified >= saves[i + 1].modified);
531        }
532    }
533
534    // ── describe_capture ─────────────────────────────────────────────
535
536    #[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}