Skip to main content

notalterra/
gvas.rs

1//! UE4/UE5 GVAS save-file binary parser.
2//!
3//! The GVAS serialization format is defined by the public Unreal Engine 5
4//! source code — the SaveGame system and its binary layout are part of the
5//! engine's open API.
6//!
7//! Ported from `legacy/extract_save_name.py`.  Extracts `SlotName` and
8//! `DisplayName` properties via manual binary walking, plus corruption
9//! detection by cross-referencing metadata against the canonical filename
10//! convention (`savegame_N.sav` / `savegame_N_M.bak`).
11
12use anyhow::{Context, Result};
13use std::fs;
14use std::path::Path;
15
16// ── low-level binary primitives ───────────────────────────────────────────
17
18/// Read a little-endian u32 at `offset`, returning `None` if out of bounds.
19fn read_u32(data: &[u8], offset: usize) -> Option<usize> {
20    if offset + 4 > data.len() {
21        return None;
22    }
23    Some(u32::from_le_bytes([
24        data[offset],
25        data[offset + 1],
26        data[offset + 2],
27        data[offset + 3],
28    ]) as usize)
29}
30
31/// Read a little-endian i32 at `offset`, returning `None` if out of bounds.
32fn read_i32(data: &[u8], offset: usize) -> Option<i64> {
33    if offset + 4 > data.len() {
34        return None;
35    }
36    Some(i32::from_le_bytes([
37        data[offset],
38        data[offset + 1],
39        data[offset + 2],
40        data[offset + 3],
41    ]) as i64)
42}
43
44/// Read a length-prefixed, null-terminated FName string.
45///
46/// Layout: `<u32 length><bytes><optional null>`
47/// Returns (string, new_offset) or (None, offset) on failure.
48fn read_fname(data: &[u8], offset: usize) -> (Option<String>, usize) {
49    let len = match read_u32(data, offset) {
50        Some(l) => l,
51        None => return (None, offset),
52    };
53    let mut off = offset + 4;
54    if len == 0 || off + len > data.len() {
55        return (None, off);
56    }
57    let mut raw = &data[off..off + len];
58    if raw.last() == Some(&0) {
59        raw = &raw[..raw.len() - 1];
60    }
61    off += len;
62    let s = String::from_utf8_lossy(raw).into_owned();
63    (Some(s), off)
64}
65
66/// Read an FString: length-prefixed, possibly UTF-16.
67///
68/// Layout: `<i32 length>` – negative means UTF-16 with `-len` chars,
69/// positive means UTF-8 byte count (including null terminator).
70/// Returns (string, new_offset).
71fn read_fstring(data: &[u8], offset: usize) -> (Option<String>, usize) {
72    let raw_len = match read_i32(data, offset) {
73        Some(l) => l,
74        None => return (None, offset),
75    };
76    let mut off = offset + 4;
77
78    if raw_len == 0 {
79        return (Some(String::new()), off);
80    }
81
82    let (bytes, is_utf16): (usize, bool) = if raw_len < 0 {
83        ((-raw_len) as usize * 2, true)
84    } else {
85        (raw_len as usize, false)
86    };
87
88    if off + bytes > data.len() {
89        return (None, off);
90    }
91
92    let mut raw = &data[off..off + bytes];
93    off += bytes;
94
95    let value = if is_utf16 {
96        // Decode UTF-16 LE
97        let code_units: Vec<u16> = raw
98            .chunks_exact(2)
99            .map(|c| u16::from_le_bytes([c[0], c[1]]))
100            .collect();
101        String::from_utf16_lossy(&code_units)
102    } else {
103        if raw.last() == Some(&0) {
104            raw = &raw[..raw.len() - 1];
105        }
106        String::from_utf8_lossy(raw).into_owned()
107    };
108
109    (Some(value), off)
110}
111
112// ── property extraction ────────────────────────────────────────────────────
113
114/// Find the first `StrProperty` named `prop_name` and return its FString value.
115///
116/// Walks the binary looking for the FName header of the property, then
117/// skips past the StrProperty metadata to read the string value.
118fn extract_str_property(data: &[u8], prop_name: &str) -> Result<String, String> {
119    let target = prop_name.as_bytes();
120    let mut offset = 0usize;
121    let mut attempts = 0u32;
122
123    while offset < data.len().saturating_sub(20) && attempts < 100 {
124        let found = match data[offset..]
125            .windows(target.len())
126            .position(|w| w == target)
127        {
128            Some(p) => offset + p,
129            None => return Err(format!("{prop_name} not found")),
130        };
131
132        // Each candidate must be a proper FName: preceded by a length dword
133        // matching the target length + 1 (null terminator), followed by a
134        // null byte.
135        if found < 4 {
136            offset = found + 1;
137            attempts += 1;
138            continue;
139        }
140
141        let name_len_field = read_u32(data, found - 4);
142        if name_len_field != Some(target.len() + 1) {
143            offset = found + 1;
144            attempts += 1;
145            continue;
146        }
147        if found + target.len() >= data.len() || data[found + target.len()] != 0 {
148            offset = found + 1;
149            attempts += 1;
150            continue;
151        }
152
153        let after_name = found + target.len() + 1;
154        let (next_name, next_offset) = read_fname(data, after_name);
155        if next_name.as_deref() != Some("StrProperty") {
156            offset = found + 1;
157            attempts += 1;
158            continue;
159        }
160
161        // Skip StrProperty metadata: 9 bytes of property flags + padding,
162        // then the FString value.
163        let meta_offset = next_offset + 9;
164        if meta_offset + 4 > data.len() {
165            offset = found + 1;
166            attempts += 1;
167            continue;
168        }
169
170        let (value, _) = read_fstring(data, meta_offset);
171        match value {
172            Some(v) if !v.is_empty() && v.len() < 100 => return Ok(v),
173            _ => {
174                offset = found + 1;
175                attempts += 1;
176            }
177        }
178    }
179
180    Err(format!("no valid {prop_name}/StrProperty pair found"))
181}
182
183/// Find a BoolProperty by name and return its value (true/false).
184fn extract_bool_property(data: &[u8], prop_name: &str) -> Option<bool> {
185    let target = prop_name.as_bytes();
186    let mut offset = 0usize;
187    let mut attempts = 0u32;
188    while offset < data.len().saturating_sub(20) && attempts < 100 {
189        let found = data[offset..]
190            .windows(target.len())
191            .position(|w| w == target);
192        let found = match found {
193            Some(p) => offset + p,
194            None => return None,
195        };
196        if found < 4 {
197            offset = found + 1;
198            attempts += 1;
199            continue;
200        }
201        let name_len_field = read_u32(data, found - 4);
202        if name_len_field != Some(target.len() + 1) {
203            offset = found + 1;
204            attempts += 1;
205            continue;
206        }
207        if found + target.len() >= data.len() || data[found + target.len()] != 0 {
208            offset = found + 1;
209            attempts += 1;
210            continue;
211        }
212        let after_name = found + target.len() + 1;
213        let (next_name, next_offset) = read_fname(data, after_name);
214        if next_name.as_deref() != Some("BoolProperty") {
215            offset = found + 1;
216            attempts += 1;
217            continue;
218        }
219        let val_offset = next_offset + 9;
220        if val_offset >= data.len() {
221            offset = found + 1;
222            attempts += 1;
223            continue;
224        }
225        return Some(data[val_offset] != 0);
226    }
227    None
228}
229
230/// Scan for a double value near a marker byte sequence.
231fn scan_double_near(data: &[u8], marker: &[u8]) -> Option<f64> {
232    let pos = data.windows(marker.len()).position(|w| w == marker)?;
233    let _end = (pos + 60).min(data.len());
234    for off in 8..50 {
235        if pos + off + 8 > data.len() {
236            break;
237        }
238        let val = f64::from_le_bytes(data[pos + off..pos + off + 8].try_into().ok()?);
239        if val > 60.0 && val < 10_000_000.0 {
240            return Some(val);
241        }
242    }
243    None
244}
245
246/// Find an IntProperty by name and return its u32 value.
247fn extract_double_property(data: &[u8], prop_name: &str) -> Option<f64> {
248    let target = prop_name.as_bytes();
249    let mut offset = 0usize;
250    let mut attempts = 0u32;
251    while offset < data.len().saturating_sub(30) && attempts < 100 {
252        let found = data[offset..]
253            .windows(target.len())
254            .position(|w| w == target);
255        let found = match found {
256            Some(p) => offset + p,
257            None => return None,
258        };
259        if found < 4 {
260            offset = found + 1;
261            attempts += 1;
262            continue;
263        }
264        let expected: usize = target.len() + 1;
265        if read_u32(data, found - 4) != Some(expected) {
266            offset = found + 1;
267            attempts += 1;
268            continue;
269        }
270        if found + target.len() >= data.len() || data[found + target.len()] != 0 {
271            offset = found + 1;
272            attempts += 1;
273            continue;
274        }
275        let (next_name, next_offset) = read_fname(data, found + target.len() + 1);
276        if next_name.as_deref() != Some("DoubleProperty") {
277            offset = found + 1;
278            attempts += 1;
279            continue;
280        }
281        let val_offset = next_offset + 9;
282        if val_offset + 8 > data.len() {
283            offset = found + 1;
284            attempts += 1;
285            continue;
286        }
287        return Some(f64::from_le_bytes(
288            data[val_offset..val_offset + 8].try_into().ok()?,
289        ));
290    }
291    None
292}
293
294/// Extract an integer property value from a key-value text pair.
295fn extract_int_property(data: &[u8], prop_name: &str) -> Option<u32> {
296    let target = prop_name.as_bytes();
297    let mut offset = 0usize;
298    let mut attempts = 0u32;
299    while offset < data.len().saturating_sub(20) && attempts < 100 {
300        let found = data[offset..]
301            .windows(target.len())
302            .position(|w| w == target);
303        let found = match found {
304            Some(p) => offset + p,
305            None => return None,
306        };
307        if found < 4 {
308            offset = found + 1;
309            attempts += 1;
310            continue;
311        }
312        if read_u32(data, found - 4) != Some(target.len() + 1) {
313            offset = found + 1;
314            attempts += 1;
315            continue;
316        }
317        if found + target.len() >= data.len() || data[found + target.len()] != 0 {
318            offset = found + 1;
319            attempts += 1;
320            continue;
321        }
322        let (next_name, next_offset) = read_fname(data, found + target.len() + 1);
323        if next_name.as_deref() != Some("IntProperty") {
324            offset = found + 1;
325            attempts += 1;
326            continue;
327        }
328        let val_offset = next_offset + 9;
329        if val_offset + 4 > data.len() {
330            offset = found + 1;
331            attempts += 1;
332            continue;
333        }
334        return read_u32(data, val_offset).map(|v| v as u32);
335    }
336    None
337}
338
339// ── public API ─────────────────────────────────────────────────────────────
340
341/// Full metadata from all known GVAS properties.
342#[derive(Debug, Clone, Default)]
343pub struct FullMetadata {
344    pub slot_name: Option<String>,
345    pub display_name: Option<String>,
346    pub is_online: bool,
347    pub was_multiplayer: bool,
348    pub game_mode: Option<String>,
349    pub level_name: Option<String>,
350    pub build_number: Option<u32>,
351    pub build_branch: Option<String>,
352    pub saves_count: Option<u32>,
353    pub latest_version: Option<u32>,
354    pub data_version: Option<u32>,
355    pub playtime_seconds: Option<f64>,
356}
357
358/// Parse a `.sav` or `.bak` file and return all known GVAS metadata.
359pub fn extract_full_metadata(path: &Path) -> Result<FullMetadata> {
360    let data = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
361    Ok(FullMetadata {
362        slot_name: extract_str_property(&data, "SlotName").ok(),
363        display_name: extract_str_property(&data, "DisplayName").ok(),
364        is_online: extract_bool_property(&data, "bIsMultiplayerSave").unwrap_or(false),
365        was_multiplayer: extract_bool_property(&data, "bWasMultiplayerSave").unwrap_or(false),
366        game_mode: extract_str_property(&data, "GameMode").ok(),
367        level_name: extract_str_property(&data, "LevelName").ok(),
368        build_number: extract_int_property(&data, "BuildNumber"),
369        build_branch: extract_str_property(&data, "BuildBranch").ok(),
370        saves_count: extract_int_property(&data, "SavesCount"),
371        latest_version: extract_int_property(&data, "LatestVersion"),
372        data_version: extract_int_property(&data, "DataVersion"),
373        playtime_seconds: scan_double_near(&data, b"Elapsed"),
374    })
375}
376
377/// Extracted metadata from a GVAS save file.
378#[derive(Debug, Clone, Default)]
379/// Extracted metadata from a Subnautica 2 GVAS save file.
380///
381/// Each field corresponds to a named UE5 property inside the save binary.
382/// Playtime is derived from the `PlaytimeData` structure when available,
383/// falling back to a byte-scan heuristic.
384pub struct SaveMetadata {
385    /// Internal slot name, e.g. "savegame_0"
386    pub slot_name: Option<String>,
387    /// Human-readable display name entered in-game
388    pub display_name: Option<String>,
389    /// Current online/multiplayer status (bIsMultiplayerSave)
390    pub is_online: bool,
391    /// Total playtime in seconds
392    pub playtime_seconds: Option<f64>,
393    /// Any extraction errors (non-fatal)
394    pub errors: Vec<String>,
395}
396
397/// Parse a `.sav` or `.bak` file and return its GVAS metadata.
398/// Parse GVAS metadata from an in-memory byte slice.  Useful for fuzzing.
399pub fn extract_metadata_from_bytes(data: &[u8]) -> Result<SaveMetadata> {
400    let mut errors = Vec::new();
401    let slot_name = match extract_str_property(data, "SlotName") {
402        Ok(v) => Some(v),
403        Err(e) => {
404            errors.push(e);
405            None
406        }
407    };
408    let display_name = match extract_str_property(data, "DisplayName") {
409        Ok(v) => Some(v),
410        Err(e) => {
411            errors.push(e);
412            None
413        }
414    };
415    Ok(SaveMetadata {
416        slot_name,
417        display_name,
418        is_online: extract_bool_property(data, "OnlineMode").unwrap_or(false),
419        playtime_seconds: extract_double_property(data, "PlayTime"),
420        errors,
421    })
422}
423
424pub fn extract_metadata(path: &Path) -> Result<SaveMetadata> {
425    let data = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
426
427    let mut errors = Vec::new();
428    let slot_name = match extract_str_property(&data, "SlotName") {
429        Ok(v) => Some(v),
430        Err(e) => {
431            errors.push(e);
432            None
433        }
434    };
435    let display_name = match extract_str_property(&data, "DisplayName") {
436        Ok(v) => Some(v),
437        Err(e) => {
438            errors.push(e);
439            None
440        }
441    };
442    let is_online = extract_bool_property(&data, "bIsMultiplayerSave").unwrap_or(false);
443
444    let playtime_seconds = scan_double_near(&data, b"Elapsed");
445    Ok(SaveMetadata {
446        slot_name,
447        display_name,
448        is_online,
449        playtime_seconds,
450        errors,
451    })
452}
453
454// ── filename conventions ───────────────────────────────────────────────────
455
456/// Derive the expected slot name from a filename.
457///
458/// `savegame_2_9.sav` → `"savegame_2"`
459/// `savegame_0.bak`   → `"savegame_0"`
460/// `random.sav`       → `None`
461pub fn derive_slot_from_filename(filename: &str) -> Option<String> {
462    let re = regex::Regex::new(r"^(savegame_\d+)").ok()?;
463    re.captures(filename)
464        .and_then(|caps| caps.get(1))
465        .map(|m| m.as_str().to_string())
466}
467
468/// Return a corruption reason, or `None` if the file looks clean.
469pub fn corruption_check(filename: &str, slot_name: Option<&str>) -> Option<String> {
470    let expected_slot = derive_slot_from_filename(filename);
471
472    if expected_slot.is_none() {
473        return Some("nonstandard filename".into());
474    }
475
476    let expected = expected_slot.unwrap();
477
478    if let Some(sn) = slot_name {
479        if sn != expected {
480            return Some(format!("slot mismatch ({sn})"));
481        }
482    }
483
484    // Non-canonical .sav: savegame_N_M.sav is not a live file
485    if filename.ends_with(".sav") {
486        let re = regex::Regex::new(r"^savegame_\d+\.sav$").ok()?;
487        if !re.is_match(filename) {
488            return Some("non-canonical .sav".into());
489        }
490    }
491
492    None
493}
494
495// ── tests ──────────────────────────────────────────────────────────────────
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn test_derive_slot() {
503        assert_eq!(
504            derive_slot_from_filename("savegame_0.sav"),
505            Some("savegame_0".into())
506        );
507        assert_eq!(
508            derive_slot_from_filename("savegame_2_9.bak"),
509            Some("savegame_2".into())
510        );
511        assert_eq!(
512            derive_slot_from_filename("savegame_0.bak"),
513            Some("savegame_0".into())
514        );
515        assert_eq!(derive_slot_from_filename("random.sav"), None);
516    }
517
518    #[test]
519    fn test_corruption_check() {
520        // Canonical live file
521        assert_eq!(corruption_check("savegame_0.sav", Some("savegame_0")), None);
522        // Versioned .sav is non-canonical
523        assert_eq!(
524            corruption_check("savegame_0_9.sav", Some("savegame_0")),
525            Some("non-canonical .sav".into())
526        );
527        // Slot mismatch
528        assert_eq!(
529            corruption_check("savegame_1.bak", Some("savegame_0")),
530            Some("slot mismatch (savegame_0)".into())
531        );
532        // Backup that matches
533        assert_eq!(
534            corruption_check("savegame_2_5.bak", Some("savegame_2")),
535            None
536        );
537    }
538
539    #[test]
540    fn dump_all_samples() {
541        use chrono::TimeZone;
542        let dir = std::path::Path::new("samples");
543        let Ok(entries) = std::fs::read_dir(dir) else {
544            return;
545        };
546        let mut files: Vec<_> = entries
547            .filter_map(|e| e.ok())
548            .filter(|e| {
549                let n = e.file_name();
550                let s = n.to_string_lossy();
551                s.ends_with(".sav") || s.ends_with(".bak")
552            })
553            .collect();
554        // Sort by slot (extracted from filename), then mtime desc
555        files.sort_by(|a, b| {
556            use crate::gvas::derive_slot_from_filename;
557            let sa = derive_slot_from_filename(&a.file_name().to_string_lossy());
558            let sb = derive_slot_from_filename(&b.file_name().to_string_lossy());
559            let ma = a.metadata().ok().and_then(|m| m.modified().ok());
560            let mb = b.metadata().ok().and_then(|m| m.modified().ok());
561            sa.cmp(&sb).then_with(|| mb.cmp(&ma))
562        });
563        println!(
564            "\n{:<8} {:<26} {:<6} {:>7}  {:<19}  {:<28}",
565            "", "Display Name", "Type", "Size", "Date", "File"
566        );
567        println!("{}", "-".repeat(115));
568        let mut seen = std::collections::HashSet::new();
569        for entry in &files {
570            let path = entry.path();
571            let name = entry.file_name().to_string_lossy().to_string();
572            let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
573            let mtime = entry
574                .metadata()
575                .ok()
576                .and_then(|m| m.modified().ok())
577                .and_then(|t| {
578                    let s = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
579                    chrono::Local.timestamp_opt(s as i64, 0).single()
580                })
581                .map(|dt| dt.format("%Y-%b-%d %H:%M").to_string());
582            let meta = extract_metadata(&path).ok();
583            let slot = meta
584                .as_ref()
585                .and_then(|m| m.slot_name.clone())
586                .unwrap_or_else(|| derive_slot_from_filename(&name).unwrap_or_else(|| "?".into()));
587            let display = meta
588                .as_ref()
589                .and_then(|m| m.display_name.clone())
590                .unwrap_or_else(|| "(unnamed)".into());
591            let online = meta.map(|m| m.is_online).unwrap_or(false);
592            let num = slot.strip_prefix("savegame_").unwrap_or(&slot);
593            let first = seen.insert(slot.clone());
594            let label = if first {
595                format!("Slot {num}")
596            } else {
597                String::new()
598            };
599            let typ = if online { "Online" } else { "Local" };
600            let sz = if size < 1024 {
601                format!("{size} B")
602            } else if size < 1_048_576 {
603                format!("{:.0} KB", size as f64 / 1024.0)
604            } else {
605                format!("{:.1} MB", size as f64 / 1_048_576.0)
606            };
607            println!(
608                "{label:<8} {display:<26} {typ:<6} {sz:>7}  {:<19}  {name:<28}",
609                mtime.as_deref().unwrap_or("?")
610            );
611        }
612        println!();
613    }
614
615    #[test]
616    /// Test helper — dump full GVAS metadata for a sample file.
617    fn print_full_meta() {
618        let p = Path::new("samples/savegame_1.sav");
619        if !p.exists() {
620            return;
621        }
622        let m = extract_full_metadata(p).unwrap();
623        println!("slot: {:?}", m.slot_name);
624        println!("display: {:?}", m.display_name);
625        println!("online: {}", m.is_online);
626        println!("was_multi: {}", m.was_multiplayer);
627        println!("gamemode: {:?}", m.game_mode);
628        println!("level: {:?}", m.level_name);
629        println!("build: {:?}", m.build_number);
630        println!("branch: {:?}", m.build_branch);
631        println!("savescnt: {:?}", m.saves_count);
632        println!("latest: {:?}", m.latest_version);
633        println!("dataver: {:?}", m.data_version);
634    }
635
636    #[test]
637    fn test_real_sample() {
638        let p = Path::new("samples/savegame_0.sav");
639        if p.exists() {
640            let meta = extract_metadata(p).unwrap();
641            assert!(meta.slot_name.is_some() || !meta.errors.is_empty());
642        }
643    }
644}