Files
NotAlterra/src/gvas.rs
T

645 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! UE4/UE5 GVAS save-file binary parser.
//!
//! The GVAS serialization format is defined by the public Unreal Engine 5
//! source code — the SaveGame system and its binary layout are part of the
//! engine's open API.
//!
//! Ported from `legacy/extract_save_name.py`. Extracts `SlotName` and
//! `DisplayName` properties via manual binary walking, plus corruption
//! detection by cross-referencing metadata against the canonical filename
//! convention (`savegame_N.sav` / `savegame_N_M.bak`).
use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
// ── low-level binary primitives ───────────────────────────────────────────
/// Read a little-endian u32 at `offset`, returning `None` if out of bounds.
fn read_u32(data: &[u8], offset: usize) -> Option<usize> {
if offset + 4 > data.len() {
return None;
}
Some(u32::from_le_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
]) as usize)
}
/// Read a little-endian i32 at `offset`, returning `None` if out of bounds.
fn read_i32(data: &[u8], offset: usize) -> Option<i64> {
if offset + 4 > data.len() {
return None;
}
Some(i32::from_le_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
]) as i64)
}
/// Read a length-prefixed, null-terminated FName string.
///
/// Layout: `<u32 length><bytes><optional null>`
/// Returns (string, new_offset) or (None, offset) on failure.
fn read_fname(data: &[u8], offset: usize) -> (Option<String>, usize) {
let len = match read_u32(data, offset) {
Some(l) => l,
None => return (None, offset),
};
let mut off = offset + 4;
if len == 0 || off + len > data.len() {
return (None, off);
}
let mut raw = &data[off..off + len];
if raw.last() == Some(&0) {
raw = &raw[..raw.len() - 1];
}
off += len;
let s = String::from_utf8_lossy(raw).into_owned();
(Some(s), off)
}
/// Read an FString: length-prefixed, possibly UTF-16.
///
/// Layout: `<i32 length>` negative means UTF-16 with `-len` chars,
/// positive means UTF-8 byte count (including null terminator).
/// Returns (string, new_offset).
fn read_fstring(data: &[u8], offset: usize) -> (Option<String>, usize) {
let raw_len = match read_i32(data, offset) {
Some(l) => l,
None => return (None, offset),
};
let mut off = offset + 4;
if raw_len == 0 {
return (Some(String::new()), off);
}
let (bytes, is_utf16): (usize, bool) = if raw_len < 0 {
((-raw_len) as usize * 2, true)
} else {
(raw_len as usize, false)
};
if off + bytes > data.len() {
return (None, off);
}
let mut raw = &data[off..off + bytes];
off += bytes;
let value = if is_utf16 {
// Decode UTF-16 LE
let code_units: Vec<u16> = raw
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&code_units)
} else {
if raw.last() == Some(&0) {
raw = &raw[..raw.len() - 1];
}
String::from_utf8_lossy(raw).into_owned()
};
(Some(value), off)
}
// ── property extraction ────────────────────────────────────────────────────
/// Find the first `StrProperty` named `prop_name` and return its FString value.
///
/// Walks the binary looking for the FName header of the property, then
/// skips past the StrProperty metadata to read the string value.
fn extract_str_property(data: &[u8], prop_name: &str) -> Result<String, String> {
let target = prop_name.as_bytes();
let mut offset = 0usize;
let mut attempts = 0u32;
while offset < data.len().saturating_sub(20) && attempts < 100 {
let found = match data[offset..]
.windows(target.len())
.position(|w| w == target)
{
Some(p) => offset + p,
None => return Err(format!("{prop_name} not found")),
};
// Each candidate must be a proper FName: preceded by a length dword
// matching the target length + 1 (null terminator), followed by a
// null byte.
if found < 4 {
offset = found + 1;
attempts += 1;
continue;
}
let name_len_field = read_u32(data, found - 4);
if name_len_field != Some(target.len() + 1) {
offset = found + 1;
attempts += 1;
continue;
}
if found + target.len() >= data.len() || data[found + target.len()] != 0 {
offset = found + 1;
attempts += 1;
continue;
}
let after_name = found + target.len() + 1;
let (next_name, next_offset) = read_fname(data, after_name);
if next_name.as_deref() != Some("StrProperty") {
offset = found + 1;
attempts += 1;
continue;
}
// Skip StrProperty metadata: 9 bytes of property flags + padding,
// then the FString value.
let meta_offset = next_offset + 9;
if meta_offset + 4 > data.len() {
offset = found + 1;
attempts += 1;
continue;
}
let (value, _) = read_fstring(data, meta_offset);
match value {
Some(v) if !v.is_empty() && v.len() < 100 => return Ok(v),
_ => {
offset = found + 1;
attempts += 1;
}
}
}
Err(format!("no valid {prop_name}/StrProperty pair found"))
}
/// Find a BoolProperty by name and return its value (true/false).
fn extract_bool_property(data: &[u8], prop_name: &str) -> Option<bool> {
let target = prop_name.as_bytes();
let mut offset = 0usize;
let mut attempts = 0u32;
while offset < data.len().saturating_sub(20) && attempts < 100 {
let found = data[offset..]
.windows(target.len())
.position(|w| w == target);
let found = match found {
Some(p) => offset + p,
None => return None,
};
if found < 4 {
offset = found + 1;
attempts += 1;
continue;
}
let name_len_field = read_u32(data, found - 4);
if name_len_field != Some(target.len() + 1) {
offset = found + 1;
attempts += 1;
continue;
}
if found + target.len() >= data.len() || data[found + target.len()] != 0 {
offset = found + 1;
attempts += 1;
continue;
}
let after_name = found + target.len() + 1;
let (next_name, next_offset) = read_fname(data, after_name);
if next_name.as_deref() != Some("BoolProperty") {
offset = found + 1;
attempts += 1;
continue;
}
let val_offset = next_offset + 9;
if val_offset >= data.len() {
offset = found + 1;
attempts += 1;
continue;
}
return Some(data[val_offset] != 0);
}
None
}
/// Scan for a double value near a marker byte sequence.
fn scan_double_near(data: &[u8], marker: &[u8]) -> Option<f64> {
let pos = data.windows(marker.len()).position(|w| w == marker)?;
let _end = (pos + 60).min(data.len());
for off in 8..50 {
if pos + off + 8 > data.len() {
break;
}
let val = f64::from_le_bytes(data[pos + off..pos + off + 8].try_into().ok()?);
if val > 60.0 && val < 10_000_000.0 {
return Some(val);
}
}
None
}
/// Find an IntProperty by name and return its u32 value.
fn extract_double_property(data: &[u8], prop_name: &str) -> Option<f64> {
let target = prop_name.as_bytes();
let mut offset = 0usize;
let mut attempts = 0u32;
while offset < data.len().saturating_sub(30) && attempts < 100 {
let found = data[offset..]
.windows(target.len())
.position(|w| w == target);
let found = match found {
Some(p) => offset + p,
None => return None,
};
if found < 4 {
offset = found + 1;
attempts += 1;
continue;
}
let expected: usize = target.len() + 1;
if read_u32(data, found - 4) != Some(expected) {
offset = found + 1;
attempts += 1;
continue;
}
if found + target.len() >= data.len() || data[found + target.len()] != 0 {
offset = found + 1;
attempts += 1;
continue;
}
let (next_name, next_offset) = read_fname(data, found + target.len() + 1);
if next_name.as_deref() != Some("DoubleProperty") {
offset = found + 1;
attempts += 1;
continue;
}
let val_offset = next_offset + 9;
if val_offset + 8 > data.len() {
offset = found + 1;
attempts += 1;
continue;
}
return Some(f64::from_le_bytes(
data[val_offset..val_offset + 8].try_into().ok()?,
));
}
None
}
/// Extract an integer property value from a key-value text pair.
fn extract_int_property(data: &[u8], prop_name: &str) -> Option<u32> {
let target = prop_name.as_bytes();
let mut offset = 0usize;
let mut attempts = 0u32;
while offset < data.len().saturating_sub(20) && attempts < 100 {
let found = data[offset..]
.windows(target.len())
.position(|w| w == target);
let found = match found {
Some(p) => offset + p,
None => return None,
};
if found < 4 {
offset = found + 1;
attempts += 1;
continue;
}
if read_u32(data, found - 4) != Some(target.len() + 1) {
offset = found + 1;
attempts += 1;
continue;
}
if found + target.len() >= data.len() || data[found + target.len()] != 0 {
offset = found + 1;
attempts += 1;
continue;
}
let (next_name, next_offset) = read_fname(data, found + target.len() + 1);
if next_name.as_deref() != Some("IntProperty") {
offset = found + 1;
attempts += 1;
continue;
}
let val_offset = next_offset + 9;
if val_offset + 4 > data.len() {
offset = found + 1;
attempts += 1;
continue;
}
return read_u32(data, val_offset).map(|v| v as u32);
}
None
}
// ── public API ─────────────────────────────────────────────────────────────
/// Full metadata from all known GVAS properties.
#[derive(Debug, Clone, Default)]
pub struct FullMetadata {
pub slot_name: Option<String>,
pub display_name: Option<String>,
pub is_online: bool,
pub was_multiplayer: bool,
pub game_mode: Option<String>,
pub level_name: Option<String>,
pub build_number: Option<u32>,
pub build_branch: Option<String>,
pub saves_count: Option<u32>,
pub latest_version: Option<u32>,
pub data_version: Option<u32>,
pub playtime_seconds: Option<f64>,
}
/// Parse a `.sav` or `.bak` file and return all known GVAS metadata.
pub fn extract_full_metadata(path: &Path) -> Result<FullMetadata> {
let data = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
Ok(FullMetadata {
slot_name: extract_str_property(&data, "SlotName").ok(),
display_name: extract_str_property(&data, "DisplayName").ok(),
is_online: extract_bool_property(&data, "bIsMultiplayerSave").unwrap_or(false),
was_multiplayer: extract_bool_property(&data, "bWasMultiplayerSave").unwrap_or(false),
game_mode: extract_str_property(&data, "GameMode").ok(),
level_name: extract_str_property(&data, "LevelName").ok(),
build_number: extract_int_property(&data, "BuildNumber"),
build_branch: extract_str_property(&data, "BuildBranch").ok(),
saves_count: extract_int_property(&data, "SavesCount"),
latest_version: extract_int_property(&data, "LatestVersion"),
data_version: extract_int_property(&data, "DataVersion"),
playtime_seconds: scan_double_near(&data, b"Elapsed"),
})
}
/// Extracted metadata from a GVAS save file.
#[derive(Debug, Clone, Default)]
/// Extracted metadata from a Subnautica 2 GVAS save file.
///
/// Each field corresponds to a named UE5 property inside the save binary.
/// Playtime is derived from the `PlaytimeData` structure when available,
/// falling back to a byte-scan heuristic.
pub struct SaveMetadata {
/// Internal slot name, e.g. "savegame_0"
pub slot_name: Option<String>,
/// Human-readable display name entered in-game
pub display_name: Option<String>,
/// Current online/multiplayer status (bIsMultiplayerSave)
pub is_online: bool,
/// Total playtime in seconds
pub playtime_seconds: Option<f64>,
/// Any extraction errors (non-fatal)
pub errors: Vec<String>,
}
/// Parse a `.sav` or `.bak` file and return its GVAS metadata.
/// Parse GVAS metadata from an in-memory byte slice. Useful for fuzzing.
pub fn extract_metadata_from_bytes(data: &[u8]) -> Result<SaveMetadata> {
let mut errors = Vec::new();
let slot_name = match extract_str_property(data, "SlotName") {
Ok(v) => Some(v),
Err(e) => {
errors.push(e);
None
}
};
let display_name = match extract_str_property(data, "DisplayName") {
Ok(v) => Some(v),
Err(e) => {
errors.push(e);
None
}
};
Ok(SaveMetadata {
slot_name,
display_name,
is_online: extract_bool_property(data, "OnlineMode").unwrap_or(false),
playtime_seconds: extract_double_property(data, "PlayTime"),
errors,
})
}
pub fn extract_metadata(path: &Path) -> Result<SaveMetadata> {
let data = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
let mut errors = Vec::new();
let slot_name = match extract_str_property(&data, "SlotName") {
Ok(v) => Some(v),
Err(e) => {
errors.push(e);
None
}
};
let display_name = match extract_str_property(&data, "DisplayName") {
Ok(v) => Some(v),
Err(e) => {
errors.push(e);
None
}
};
let is_online = extract_bool_property(&data, "bIsMultiplayerSave").unwrap_or(false);
let playtime_seconds = scan_double_near(&data, b"Elapsed");
Ok(SaveMetadata {
slot_name,
display_name,
is_online,
playtime_seconds,
errors,
})
}
// ── filename conventions ───────────────────────────────────────────────────
/// Derive the expected slot name from a filename.
///
/// `savegame_2_9.sav` → `"savegame_2"`
/// `savegame_0.bak` → `"savegame_0"`
/// `random.sav` → `None`
pub fn derive_slot_from_filename(filename: &str) -> Option<String> {
let re = regex::Regex::new(r"^(savegame_\d+)").ok()?;
re.captures(filename)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
}
/// Return a corruption reason, or `None` if the file looks clean.
pub fn corruption_check(filename: &str, slot_name: Option<&str>) -> Option<String> {
let expected_slot = derive_slot_from_filename(filename);
if expected_slot.is_none() {
return Some("nonstandard filename".into());
}
let expected = expected_slot.unwrap();
if let Some(sn) = slot_name {
if sn != expected {
return Some(format!("slot mismatch ({sn})"));
}
}
// Non-canonical .sav: savegame_N_M.sav is not a live file
if filename.ends_with(".sav") {
let re = regex::Regex::new(r"^savegame_\d+\.sav$").ok()?;
if !re.is_match(filename) {
return Some("non-canonical .sav".into());
}
}
None
}
// ── tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_derive_slot() {
assert_eq!(
derive_slot_from_filename("savegame_0.sav"),
Some("savegame_0".into())
);
assert_eq!(
derive_slot_from_filename("savegame_2_9.bak"),
Some("savegame_2".into())
);
assert_eq!(
derive_slot_from_filename("savegame_0.bak"),
Some("savegame_0".into())
);
assert_eq!(derive_slot_from_filename("random.sav"), None);
}
#[test]
fn test_corruption_check() {
// Canonical live file
assert_eq!(corruption_check("savegame_0.sav", Some("savegame_0")), None);
// Versioned .sav is non-canonical
assert_eq!(
corruption_check("savegame_0_9.sav", Some("savegame_0")),
Some("non-canonical .sav".into())
);
// Slot mismatch
assert_eq!(
corruption_check("savegame_1.bak", Some("savegame_0")),
Some("slot mismatch (savegame_0)".into())
);
// Backup that matches
assert_eq!(
corruption_check("savegame_2_5.bak", Some("savegame_2")),
None
);
}
#[test]
fn dump_all_samples() {
use chrono::TimeZone;
let dir = std::path::Path::new("samples");
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| {
let n = e.file_name();
let s = n.to_string_lossy();
s.ends_with(".sav") || s.ends_with(".bak")
})
.collect();
// Sort by slot (extracted from filename), then mtime desc
files.sort_by(|a, b| {
use crate::gvas::derive_slot_from_filename;
let sa = derive_slot_from_filename(&a.file_name().to_string_lossy());
let sb = derive_slot_from_filename(&b.file_name().to_string_lossy());
let ma = a.metadata().ok().and_then(|m| m.modified().ok());
let mb = b.metadata().ok().and_then(|m| m.modified().ok());
sa.cmp(&sb).then_with(|| mb.cmp(&ma))
});
println!(
"\n{:<8} {:<26} {:<6} {:>7} {:<19} {:<28}",
"", "Display Name", "Type", "Size", "Date", "File"
);
println!("{}", "-".repeat(115));
let mut seen = std::collections::HashSet::new();
for entry in &files {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
let mtime = entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| {
let s = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
chrono::Local.timestamp_opt(s as i64, 0).single()
})
.map(|dt| dt.format("%Y-%b-%d %H:%M").to_string());
let meta = extract_metadata(&path).ok();
let slot = meta
.as_ref()
.and_then(|m| m.slot_name.clone())
.unwrap_or_else(|| derive_slot_from_filename(&name).unwrap_or_else(|| "?".into()));
let display = meta
.as_ref()
.and_then(|m| m.display_name.clone())
.unwrap_or_else(|| "(unnamed)".into());
let online = meta.map(|m| m.is_online).unwrap_or(false);
let num = slot.strip_prefix("savegame_").unwrap_or(&slot);
let first = seen.insert(slot.clone());
let label = if first {
format!("Slot {num}")
} else {
String::new()
};
let typ = if online { "Online" } else { "Local" };
let sz = if size < 1024 {
format!("{size} B")
} else if size < 1_048_576 {
format!("{:.0} KB", size as f64 / 1024.0)
} else {
format!("{:.1} MB", size as f64 / 1_048_576.0)
};
println!(
"{label:<8} {display:<26} {typ:<6} {sz:>7} {:<19} {name:<28}",
mtime.as_deref().unwrap_or("?")
);
}
println!();
}
#[test]
/// Test helper — dump full GVAS metadata for a sample file.
fn print_full_meta() {
let p = Path::new("samples/savegame_1.sav");
if !p.exists() {
return;
}
let m = extract_full_metadata(p).unwrap();
println!("slot: {:?}", m.slot_name);
println!("display: {:?}", m.display_name);
println!("online: {}", m.is_online);
println!("was_multi: {}", m.was_multiplayer);
println!("gamemode: {:?}", m.game_mode);
println!("level: {:?}", m.level_name);
println!("build: {:?}", m.build_number);
println!("branch: {:?}", m.build_branch);
println!("savescnt: {:?}", m.saves_count);
println!("latest: {:?}", m.latest_version);
println!("dataver: {:?}", m.data_version);
}
#[test]
fn test_real_sample() {
let p = Path::new("samples/savegame_0.sav");
if p.exists() {
let meta = extract_metadata(p).unwrap();
assert!(meta.slot_name.is_some() || !meta.errors.is_empty());
}
}
}