1use anyhow::{Context, Result};
13use std::fs;
14use std::path::Path;
15
16fn 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
31fn 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
44fn 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
66fn 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 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
112fn 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 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 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
183fn 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
230fn 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
246fn 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
294fn 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#[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
358pub 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#[derive(Debug, Clone, Default)]
379pub struct SaveMetadata {
385 pub slot_name: Option<String>,
387 pub display_name: Option<String>,
389 pub is_online: bool,
391 pub playtime_seconds: Option<f64>,
393 pub errors: Vec<String>,
395}
396
397pub 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
454pub 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
468pub 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 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#[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 assert_eq!(corruption_check("savegame_0.sav", Some("savegame_0")), None);
522 assert_eq!(
524 corruption_check("savegame_0_9.sav", Some("savegame_0")),
525 Some("non-canonical .sav".into())
526 );
527 assert_eq!(
529 corruption_check("savegame_1.bak", Some("savegame_0")),
530 Some("slot mismatch (savegame_0)".into())
531 );
532 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 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 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}