diff --git a/_search_save_type.py b/_search_save_type.py new file mode 100644 index 0000000..eb4a3d1 --- /dev/null +++ b/_search_save_type.py @@ -0,0 +1,15 @@ +import struct, os +samples = 'samples' +for f in sorted(os.listdir(samples)): + if not f.endswith('.sav'): continue + path = os.path.join(samples, f) + d = open(path, 'rb').read() + i = d.find(b'Elapsed') + if i >= 0: + for off in range(8, 50): + try: + v = struct.unpack_from(' Option { } /// Find an IntProperty by name and return its u32 value. +fn extract_double_property(data: &[u8], prop_name: &str) -> Option { + 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 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 +} + fn extract_int_property(data: &[u8], prop_name: &str) -> Option { let target = prop_name.as_bytes(); let mut offset = 0usize; @@ -234,6 +254,7 @@ pub struct FullMetadata { pub saves_count: Option, pub latest_version: Option, pub data_version: Option, + pub playtime_seconds: Option, } /// Parse a `.sav` or `.bak` file and return all known GVAS metadata. @@ -252,6 +273,7 @@ pub fn extract_full_metadata(path: &Path) -> Result { saves_count: extract_int_property(&data, "SavesCount"), latest_version: extract_int_property(&data, "LatestVersion"), data_version: extract_int_property(&data, "DataVersion"), + playtime_seconds: extract_double_property(&data, "ElapsedTimeDouble"), }) } @@ -264,6 +286,8 @@ pub struct SaveMetadata { pub display_name: Option, /// Current online/multiplayer status (bIsMultiplayerSave) pub is_online: bool, + /// Total playtime in seconds + pub playtime_seconds: Option, /// Any extraction errors (non-fatal) pub errors: Vec, } @@ -290,10 +314,12 @@ pub fn extract_metadata(path: &Path) -> Result { }; let is_online = extract_bool_property(&data, "bIsMultiplayerSave").unwrap_or(false); + let playtime_seconds = extract_double_property(&data, "ElapsedTimeDouble"); Ok(SaveMetadata { slot_name, display_name, is_online, + playtime_seconds, errors, }) } diff --git a/src/main.rs b/src/main.rs index 2b55b86..29f3f95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -432,11 +432,13 @@ fn action_recover_bak(terminal: &mut Terminal, app: &mut App) -> }; let date = s.mtime.as_deref().unwrap_or("?"); let save_type = if s.is_online { "Multiplayer" } else { "Single Player" }; + let playtime = format_playtime(s.playtime_seconds); format!( - " {:<8} {:<26} {:<13} {:>6} {}", + " {:<8} {:<26} {:<13} {:>8} {:>6} {}", label_col, name_col, save_type, + playtime, format_size(s.size), date, ) @@ -947,6 +949,7 @@ fn action_inspect_saves(terminal: &mut Terminal, app: &mut App) - ("Saves", ":", meta.saves_count.map_or("?".into(), |n| n.to_string())), ("Ver", ":", meta.latest_version.map_or("?".into(), |n| n.to_string())), ("DataVer", ":", meta.data_version.map_or("?".into(), |n| n.to_string())), + ("Playtime", ":", format_playtime(meta.playtime_seconds)), ]; let max_label: usize = fields.iter().map(|(k, _, _)| k.len()).max().unwrap_or(8); for (key, sep, value) in fields { @@ -1190,6 +1193,22 @@ fn fs_meta(path: &Path) -> Result { std::fs::metadata(path).map_err(|_| ()) } +fn format_playtime(seconds: Option) -> String { + match seconds { + Some(s) if s >= 3600.0 => { + let h = (s / 3600.0) as u32; + let m = ((s % 3600.0) / 60.0) as u32; + format!("{h}h {m}m") + } + Some(s) if s >= 60.0 => { + let m = (s / 60.0) as u32; + format!("{m}m") + } + Some(_) => String::from("—"), + None => String::from("—"), + } +} + /// Format a byte size human-readably. fn format_size(bytes: u64) -> String { if bytes < 1024 { diff --git a/src/ops.rs b/src/ops.rs index 003af5c..17ee71f 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -314,6 +314,7 @@ pub struct BakFileSummary { pub is_online: bool, pub size: u64, pub mtime: Option, + pub playtime_seconds: Option, } /// List .bak files with parsed GVAS metadata. @@ -362,6 +363,7 @@ pub fn list_bak_files_with_meta(save_folder: &Path) -> Vec { let meta = extract_metadata(&path).ok(); let display_name = meta.as_ref().and_then(|m| m.display_name.clone()); let is_online = meta.as_ref().map(|m| m.is_online).unwrap_or(false); + let playtime_seconds = meta.as_ref().and_then(|m| m.playtime_seconds); files.push(BakFileSummary { path, @@ -371,6 +373,7 @@ pub fn list_bak_files_with_meta(save_folder: &Path) -> Vec { is_online, size, mtime, + playtime_seconds, }); }