mirror of
https://github.com/forkless/NotAlterra.git
synced 2026-08-17 17:00:28 +02:00
Add playtime extraction and display (picker + inspector)
This commit is contained in:
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user