Add playtime extraction and display (picker + inspector)

This commit is contained in:
2026-06-01 01:55:44 +02:00
parent fd56770d14
commit 3f8f5f53d1
4 changed files with 64 additions and 1 deletions
+15
View File
@@ -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('<d', d, i+off)[0]
if 60 < v < 1000000:
h = int(v//3600); m = int((v%3600)//60)
print(f'{f}: {h}h{m}m')
except: pass
+26
View File
@@ -199,6 +199,26 @@ fn extract_bool_property(data: &[u8], prop_name: &str) -> Option<bool> {
}
/// 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 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<u32> {
let target = prop_name.as_bytes();
let mut offset = 0usize;
@@ -234,6 +254,7 @@ pub struct FullMetadata {
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.
@@ -252,6 +273,7 @@ pub fn extract_full_metadata(path: &Path) -> Result<FullMetadata> {
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<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>,
}
@@ -290,10 +314,12 @@ pub fn extract_metadata(path: &Path) -> Result<SaveMetadata> {
};
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,
})
}
+20 -1
View File
@@ -432,11 +432,13 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, 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<B: Backend>(terminal: &mut Terminal<B>, 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, ()> {
std::fs::metadata(path).map_err(|_| ())
}
fn format_playtime(seconds: Option<f64>) -> 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 {
+3
View File
@@ -314,6 +314,7 @@ pub struct BakFileSummary {
pub is_online: bool,
pub size: u64,
pub mtime: Option<String>,
pub playtime_seconds: Option<f64>,
}
/// List .bak files with parsed GVAS metadata.
@@ -362,6 +363,7 @@ pub fn list_bak_files_with_meta(save_folder: &Path) -> Vec<BakFileSummary> {
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<BakFileSummary> {
is_online,
size,
mtime,
playtime_seconds,
});
}