Initial public release

This commit is contained in:
2026-05-31 18:34:09 +02:00
commit 5496a3415b
15 changed files with 5044 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
//! config.ini read/write.
//!
//! The file sits next to the binary and stores cached paths plus the
//! disclaimer-acceptance flag. Format is a flat `[alterra]` section:
//!
//! ```ini
//! [alterra]
//! last_path = C:\Users\...\Subnautica2\Saved\SaveGames
//! last_scan = 2026-05-26 19:45:22
//! disclaimer_accepted = true
//! config_path = C:\Users\...\Subnautica2\Saved\Config\Windows
//! ```
use anyhow::{Context, Result};
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
/// In-memory representation of the config.ini keys.
#[derive(Debug, Clone, Default)]
pub struct AppConfig {
/// Last-known save folder (SaveGames)
pub last_path: Option<String>,
/// Last-known Config\Windows folder
pub config_path: Option<String>,
/// Timestamp of last successful scan
pub last_scan: Option<String>,
/// Whether disclaimer was accepted
pub disclaimer_accepted: bool,
}
/// Path to config.ini alongside the binary.
pub fn config_path() -> PathBuf {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(Path::to_path_buf))
.unwrap_or_else(|| PathBuf::from("."));
exe_dir.join("config.ini")
}
/// Read and parse config.ini.
pub fn load_config(path: &Path) -> Result<AppConfig> {
let mut cfg = AppConfig::default();
if !path.exists() {
return Ok(cfg);
}
let f = fs::File::open(path)
.with_context(|| format!("cannot open {}", path.display()))?;
let reader = BufReader::new(f);
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('[') {
continue;
}
if let Some(val) = trimmed.strip_prefix("last_path").and_then(strip_eq) {
cfg.last_path = Some(val.to_string());
} else if let Some(val) = trimmed.strip_prefix("config_path").and_then(strip_eq) {
cfg.config_path = Some(val.to_string());
} else if let Some(val) = trimmed.strip_prefix("last_scan").and_then(strip_eq) {
cfg.last_scan = Some(val.to_string());
} else if let Some(val) = trimmed.strip_prefix("disclaimer_accepted").and_then(strip_eq) {
cfg.disclaimer_accepted = val.trim() == "true";
}
}
Ok(cfg)
}
/// Strip `=` and surrounding whitespace. Returns `Some(value)`.
fn strip_eq(s: &str) -> Option<&str> {
Some(s.trim().strip_prefix('=')?.trim())
}
/// Write config back to disk, preserving non-managed keys.
pub fn save_config(path: &Path, cfg: &AppConfig) -> Result<()> {
// Preserve unknown keys by reading the old file first.
let preserved = preserved_keys(path).unwrap_or_default();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).ok();
}
let mut f = fs::File::create(path)
.with_context(|| format!("cannot write {}", path.display()))?;
writeln!(f, "[alterra]")?;
if let Some(ref lp) = cfg.last_path {
writeln!(f, "last_path = {lp}")?;
}
if let Some(ref cp) = cfg.config_path {
writeln!(f, "config_path = {cp}")?;
}
if let Some(ref ls) = cfg.last_scan {
writeln!(f, "last_scan = {ls}")?;
}
writeln!(
f,
"disclaimer_accepted = {}",
cfg.disclaimer_accepted
)?;
// Append any extra keys we didn't touch
for (k, v) in &preserved {
writeln!(f, "{k} = {v}")?;
}
Ok(())
}
/// Collect key/value pairs from the old config that this tool doesn't own.
fn preserved_keys(path: &Path) -> Result<Vec<(String, String)>> {
let known = &["last_path", "config_path", "last_scan", "disclaimer_accepted"];
let mut out = Vec::new();
let f = fs::File::open(path)?;
for line in BufReader::new(f).lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('[') {
continue;
}
if let Some(eq) = trimmed.find('=') {
let key = trimmed[..eq].trim();
if !known.contains(&key) {
let val = trimmed[eq + 1..].trim();
out.push((key.to_string(), val.to_string()));
}
}
}
Ok(out)
}
+371
View File
@@ -0,0 +1,371 @@
//! Save-folder discovery.
//!
//! Traverses known path patterns across user profiles and common install
//! locations to find Subnautica 2 save folders.
use std::fs;
use std::path::{Path, PathBuf};
/// A discovered save folder.
#[derive(Debug, Clone)]
pub struct DiscoveredFolder {
pub label: String,
pub path: PathBuf,
}
/// Known save-root patterns (relative from a user profile or base directory).
///
/// The same patterns work on both platforms because UE5 keeps the same
/// directory layout regardless of OS.
const KNOWN_PATTERNS: &[(&str, &str)] = &[
("Steam (LocalLow)", "AppData/LocalLow/Unknown Worlds/Subnautica2"),
("Steam (LocalLow, alt)", "AppData/LocalLow/Unknown Worlds/Subnautica 2"),
("AppData Local", "AppData/Local/Subnautica2/Saved/SaveGames"),
("AppData Local (alt)", "AppData/Local/Subnautica 2/Saved/SaveGames"),
("Xbox / Game Pass", "AppData/Local/Packages"), // partial — needs wildcard below
("Saved Games", "Saved Games/Subnautica2"),
("Saved Games (alt)", "Saved Games/Subnautica 2"),
("Documents", "Documents/Subnautica2"),
("Epic / Steam custom", "AppData/LocalLow/Subnautica2"),
];
/// Search all known locations for Subnautica 2 save folders.
///
/// Returns a deduplicated, ranked list. The first result is cached as
/// `last_path` in config.ini so subsequent launches skip the scan.
pub fn discover_save_folders() -> Vec<DiscoveredFolder> {
let mut found: Vec<DiscoveredFolder> = Vec::new();
let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
// 0. Fast-path: %LOCALAPPDATA%\Subnautica2\Saved\SaveGames (primary)
#[cfg(target_os = "windows")]
{
if let Some(local) = dirs::data_local_dir() {
let primary = local.join("Subnautica2").join("Saved").join("SaveGames");
if primary.exists() && has_save_files(&primary) {
found.push(DiscoveredFolder {
label: "AppData Local".into(),
path: primary,
});
return found;
}
}
}
#[cfg(not(target_os = "windows"))]
{
if let Some(data) = dirs::data_local_dir() {
let primary = data.join("Subnautica2").join("Saved").join("SaveGames");
if primary.exists() && has_save_files(&primary) {
found.push(DiscoveredFolder {
label: "XDG Data".into(),
path: primary,
});
return found;
}
}
}
// 1. Current user profile — remaining patterns
if let Some(home) = dirs::home_dir() {
for (label, rel) in KNOWN_PATTERNS {
let candidate = home.join(rel);
if candidate.exists() && candidate.is_dir() {
if has_save_files(&candidate) && seen.insert(candidate.clone()) {
found.push(DiscoveredFolder {
label: label.to_string(),
path: candidate,
});
}
}
}
}
// 2. Xbox / Game Pass wildcard scan
#[cfg(target_os = "windows")]
{
if let Some(home) = dirs::home_dir() {
let pkg_root = home.join("AppData/Local/Packages");
if pkg_root.exists() {
if let Ok(entries) = fs::read_dir(&pkg_root) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.contains("Subnautica2") {
let wgs = entry.path().join("SystemAppData/wgs");
if wgs.exists() && has_save_files(&wgs) && seen.insert(wgs.clone()) {
found.push(DiscoveredFolder {
label: "Xbox / Game Pass".into(),
path: wgs,
});
}
}
}
}
}
}
}
// 3. Fallback: scan other user profiles
scan_other_users(&mut found, &mut seen);
// 4. Broad custom-install scan
scan_common_install_dirs(&mut found, &mut seen);
found
}
/// Check if a directory contains at least one `.sav` or `.save` file.
fn has_save_files(dir: &Path) -> bool {
let check = |ext: &str| -> bool {
fs::read_dir(dir)
.map(|entries| {
entries.flatten().any(|e| {
e.file_name()
.to_string_lossy()
.ends_with(ext)
})
})
.unwrap_or(false)
};
check(".sav") || check(".save")
}
/// Scan other user profiles on the system.
#[cfg(target_os = "windows")]
fn scan_other_users(
found: &mut Vec<DiscoveredFolder>,
seen: &mut std::collections::HashSet<PathBuf>,
) {
for drive in fixed_drives() {
let users = Path::new(&drive).join("Users");
if !users.exists() {
continue;
}
if let Ok(entries) = fs::read_dir(&users) {
for user_dir in entries.flatten() {
let user_path = user_dir.path();
for (label, rel) in KNOWN_PATTERNS {
let candidate = user_path.join(rel);
if candidate.exists() && has_save_files(&candidate) && seen.insert(candidate.clone()) {
found.push(DiscoveredFolder {
label: label.to_string(),
path: candidate,
});
}
}
}
}
}
}
#[cfg(not(target_os = "windows"))]
fn scan_other_users(
found: &mut Vec<DiscoveredFolder>,
seen: &mut std::collections::HashSet<PathBuf>,
) {
// On Linux, check /home for other users
let home_root = Path::new("/home");
if !home_root.exists() {
return;
}
if let Ok(entries) = fs::read_dir(home_root) {
for user_dir in entries.flatten() {
let user_path = user_dir.path();
for (label, rel) in KNOWN_PATTERNS {
let candidate = user_path.join(rel);
if candidate.exists() && has_save_files(&candidate) && seen.insert(candidate.clone()) {
found.push(DiscoveredFolder {
label: label.to_string(),
path: candidate,
});
}
}
}
}
// Also check common Steam Deck paths
let deck_paths = &[
Path::new("/run/media/mmcblk0p1/steamapps/compatdata"),
Path::new("/home/deck/.local/share/Steam/steamapps/compatdata"),
];
for base in deck_paths {
if base.exists() {
// Walk compatdata for Subnautica 2 prefix
if let Ok(entries) = fs::read_dir(base) {
for app_entry in entries.flatten() {
let pfx = app_entry.path().join("pfx/drive_c/users/steamuser");
for (label, rel) in KNOWN_PATTERNS {
let candidate = pfx.join(rel);
if candidate.exists() && has_save_files(&candidate) && seen.insert(candidate.clone()) {
found.push(DiscoveredFolder {
label: format!("Steam Deck — {label}"),
path: candidate,
});
}
}
}
}
}
}
}
/// Scan common install directories (Program Files, Games, Steam, etc.).
#[cfg(target_os = "windows")]
fn scan_common_install_dirs(
found: &mut Vec<DiscoveredFolder>,
seen: &mut std::collections::HashSet<PathBuf>,
) {
for drive in fixed_drives() {
let roots: &[&str] = &[
&format!("{drive}Games"),
&format!("{drive}Program Files"),
&format!("{drive}Program Files (x86)"),
&format!("{drive}Steam"),
&format!("{drive}Epic Games"),
];
for rt in roots {
let p = Path::new(rt);
if !p.exists() {
continue;
}
walk_for_subnautica(p, "custom install", found, seen);
}
}
}
#[cfg(not(target_os = "windows"))]
fn scan_common_install_dirs(
found: &mut Vec<DiscoveredFolder>,
seen: &mut std::collections::HashSet<PathBuf>,
) {
let roots: &[&str] = &[
"/opt",
"/usr/local/games",
"/usr/share/games",
];
for rt in roots {
let p = Path::new(rt);
if !p.exists() {
continue;
}
walk_for_subnautica(p, "custom install", found, seen);
}
// Steam library paths
if let Some(home) = dirs::home_dir() {
let steam = home.join(".local/share/Steam");
if steam.exists() {
walk_for_subnautica(&steam, "Steam", found, seen);
}
}
}
/// Recursively walk a root looking for folders named "*Subnautica*".
fn walk_for_subnautica(
root: &Path,
label: &str,
found: &mut Vec<DiscoveredFolder>,
seen: &mut std::collections::HashSet<PathBuf>,
) {
use std::collections::VecDeque;
let mut queue: VecDeque<PathBuf> = VecDeque::new();
queue.push_back(root.to_path_buf());
while let Some(dir) = queue.pop_front() {
// Limit depth: don't recurse more than 5 levels from root
let depth = dir.components().count().saturating_sub(root.components().count());
if depth > 5 {
continue;
}
let entries = match fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
if name.contains("subnautica") {
if has_save_files(&path) && seen.insert(path.clone()) {
found.push(DiscoveredFolder {
label: label.to_string(),
path: path.clone(),
});
}
}
if path.is_dir() {
queue.push_back(path);
}
}
}
}
/// List available fixed drives on Windows.
#[cfg(target_os = "windows")]
fn fixed_drives() -> Vec<String> {
let mut drives = Vec::new();
for letter in b'A'..=b'Z' {
let path = format!("{}:\\", letter as char);
if Path::new(&path).exists() {
drives.push(path);
}
}
drives
}
// ── helpers for the TUI ──────────────────────────────────────────────────
/// Validate that a manually entered path exists and contains save files.
pub fn validate_custom_path(input: &str) -> Option<PathBuf> {
let expanded = if input.starts_with('~') {
if let Some(home) = dirs::home_dir() {
input.replacen('~', &home.to_string_lossy(), 1)
} else {
input.to_string()
}
} else {
input.to_string()
};
let path = PathBuf::from(expanded);
if path.exists() && has_save_files(&path) {
Some(path)
} else {
None
}
}
/// Derive the Config\Windows path from a SaveGames path.
///
/// Walks up to the `Saved` ancestor, then down to `Config/Windows`.
pub fn derive_config_path(save_path: &Path) -> Option<PathBuf> {
let mut current = save_path.to_path_buf();
// Walk up looking for "Saved" component
loop {
if current.file_name().map(|n| n == "Saved").unwrap_or(false) {
let config = current.join("Config").join("Windows");
return if config.exists() { Some(config) } else { None };
}
if !current.pop() {
break;
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_derive_config_path_sample() {
// Should resolve from a save path even if dirs don't exist locally
let save = Path::new("C:/Users/test/AppData/Local/Subnautica2/Saved/SaveGames");
// Since we can't test existence, at least verify the walk logic compiles
let result = derive_config_path(save);
// On a test machine without the game, this returns None — which is fine
let _ = result;
}
}
+104
View File
@@ -0,0 +1,104 @@
//! Game-running guard and transaction logging.
//!
//! Before launch and before each destructive operation, check whether
//! Subnautica 2 is running — the game holds file locks on `.sav` files
//! while active.
use anyhow::Result;
use chrono::Local;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
// ── process detection ──────────────────────────────────────────────────────
/// Return `true` if Subnautica 2 appears to be running.
#[cfg(target_os = "windows")]
pub fn game_running() -> bool {
let out = std::process::Command::new("tasklist")
.args(["/FI", "IMAGENAME eq Subnautica2.exe", "/NH"])
.output();
match out {
Ok(o) => String::from_utf8_lossy(&o.stdout).contains("Subnautica2.exe"),
Err(_) => false,
}
}
#[cfg(not(target_os = "windows"))]
pub fn game_running() -> bool {
let patterns = &["Subnautica2", "Subnautica2-Win64-Shipping"];
for pat in patterns {
if let Ok(out) = std::process::Command::new("pgrep")
.args(["-ci", pat])
.output()
{
let s = String::from_utf8_lossy(&out.stdout);
if let Ok(n) = s.trim().parse::<u32>() {
if n > 0 {
return true;
}
}
}
}
false
}
// ── transaction logging ────────────────────────────────────────────────────
/// Path to transaction.log next to the binary.
pub fn log_path() -> PathBuf {
exe_dir().join("transaction.log")
}
fn exe_dir() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(Path::to_path_buf))
.unwrap_or_else(|| PathBuf::from("."))
}
/// Maximum lines before rotation.
const MAX_LOG_LINES: usize = 10_000;
/// Append a timestamped log entry. Auto-rotates if the log exceeds 10k lines.
pub fn log_action(
action: &str,
detail: &str,
result: &str,
log_path: &Path,
) -> Result<()> {
let stamp = Local::now().format("%Y-%m-%d %H:%M:%S");
let line = format!("{stamp} | {action:<8} | {detail} | {result}\n");
// Rotate if needed
if log_path.exists() {
if let Ok(content) = fs::read_to_string(log_path) {
let lines: Vec<&str> = content.lines().collect();
if lines.len() > MAX_LOG_LINES {
let keep: String = lines[lines.len() - MAX_LOG_LINES..].join("\n");
fs::write(log_path, keep + "\n").ok();
}
}
}
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(log_path)?;
f.write_all(line.as_bytes())?;
Ok(())
}
/// Check whether a path looks like a network/UNC path (for warning purposes).
pub fn is_network_path(p: &str) -> bool {
p.starts_with("\\\\") || p.starts_with("//")
}
/// Estimate free space on the volume containing `path` in bytes.
/// Returns `None` on platforms or filesystems where we can't determine this.
pub fn available_space(_path: &Path) -> Option<u64> {
// Advisory only — the PowerShell original had a try/catch fallback.
// For a cross-platform build without platform-specific FFI, we
// return None and skip the disk-space warning.
None
}
+462
View File
@@ -0,0 +1,462 @@
//! UE4/UE5 GVAS save-file binary parser.
//!
//! 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 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 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
}
/// Find an IntProperty by name and return its u32 value.
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 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>,
}
/// 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"),
})
}
/// Extracted metadata from a GVAS save file.
#[derive(Debug, Clone, Default)]
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,
/// Any extraction errors (non-fatal)
pub errors: Vec<String>,
}
/// Parse a `.sav` or `.bak` file and return its GVAS metadata.
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);
Ok(SaveMetadata {
slot_name,
display_name,
is_online,
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-%m-%d %H:%M:%S").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]
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());
}
}
}
+1182
View File
File diff suppressed because it is too large Load Diff
+530
View File
@@ -0,0 +1,530 @@
//! File operations: recover .bak→.sav, backup/restore full, .ini management.
//!
//! The `.bak` recovery bug from the PowerShell script is fixed here:
//! versioned backups (`savegame_0_9.bak`) recover to the canonical
//! `savegame_0.sav`, not `savegame_0_9.sav`.
use crate::gvas::{derive_slot_from_filename, extract_metadata};
use anyhow::{Context, Result};
use chrono::Local;
use std::fs;
use std::path::{Path, PathBuf};
// ── public operation types ─────────────────────────────────────────────────
/// Result of a backup operation.
#[derive(Debug, Clone)]
pub struct BackupResult {
pub files_copied: usize,
pub total_size: u64,
pub dest_dir: PathBuf,
pub verified: bool,
}
/// Result of a recovery operation.
#[derive(Debug, Clone)]
pub struct RecoveryResult {
pub source: String,
pub target: String,
pub old_saved_as: Option<String>,
}
// ── .sav recovery from .bak ────────────────────────────────────────────────
/// Restore a `.sav` file from a `.bak` backup.
///
/// The target is derived from the canonical slot name (e.g.
/// `savegame_0_9.bak` → `savegame_0.sav`). If a live `.sav` exists,
/// it is renamed to `<target>.old` as a rollback safety net before
/// the backup is copied in.
pub fn recover_bak_to_sav(
save_folder: &Path,
bak_filename: &str,
) -> Result<RecoveryResult> {
let bak_path = save_folder.join(bak_filename);
if !bak_path.exists() {
anyhow::bail!("backup file not found: {}", bak_path.display());
}
// Sanity: reject tiny files (< 1 KB)
let meta = fs::metadata(&bak_path)
.with_context(|| format!("cannot read {}", bak_path.display()))?;
if meta.len() < 1024 {
anyhow::bail!(
"backup file too small ({} bytes) — aborting restore",
meta.len()
);
}
// Derive the canonical .sav target
let slot = derive_slot_from_filename(bak_filename)
.ok_or_else(|| anyhow::anyhow!("cannot derive slot from filename: {bak_filename}"))?;
let target_name = format!("{slot}.sav");
let target_path = save_folder.join(&target_name);
let mut old_saved_as = None;
// Roll the existing .sav aside
if target_path.exists() {
let old_path = save_folder.join(format!("{target_name}.old"));
fs::rename(&target_path, &old_path)
.with_context(|| format!("cannot rename {}{}", target_path.display(), old_path.display()))?;
old_saved_as = Some(format!("{target_name}.old"));
}
// Copy .bak → .sav
fs::copy(&bak_path, &target_path).with_context(|| {
format!(
"cannot copy {}{}",
bak_path.display(),
target_path.display()
)
})?;
Ok(RecoveryResult {
source: bak_filename.to_string(),
target: target_name,
old_saved_as,
})
}
// ── full backup ────────────────────────────────────────────────────────────
/// Create a full backup of the save folder to `NotAlterra_Backups/notalterra_copy_<timestamp>`.
pub fn create_full_backup(
save_folder: &Path,
backup_root: &Path,
) -> Result<BackupResult> {
let ts = Local::now().format("%Y-%m-%d_%H%M%S");
let dest = backup_root.join(format!("notalterra_copy_{ts}"));
fs::create_dir_all(&dest)
.with_context(|| format!("cannot create backup dir {}", dest.display()))?;
let mut copied = 0usize;
let mut total = 0u64;
if let Err(e) = copy_save_files(save_folder, &dest, &mut copied, &mut total) {
let _ = fs::remove_dir_all(&dest);
return Err(e);
}
// Verify
let verified = verify_backup(save_folder, &dest);
Ok(BackupResult {
files_copied: copied,
total_size: total,
dest_dir: dest,
verified,
})
}
/// Restore a full backup into the save folder.
///
/// Creates a pre-restore safety backup first.
pub fn restore_full_backup(
backup_dir: &Path,
save_folder: &Path,
backup_root: &Path,
) -> Result<()> {
// Pre-restore safety backup
let ts = Local::now().format("%Y-%m-%d_%H%M%S");
let pre_restore = backup_root.join(format!("pre_restore_{ts}"));
fs::create_dir_all(&pre_restore)
.with_context(|| format!("cannot create pre-restore dir {}", pre_restore.display()))?;
let mut dummy = 0usize;
let mut dummy_size = 0u64;
if copy_save_files(save_folder, &pre_restore, &mut dummy, &mut dummy_size).is_err() {
let _ = fs::remove_dir_all(&pre_restore);
}
// Overwrite save folder with backup
copy_save_files(backup_dir, save_folder, &mut dummy, &mut dummy_size)?;
Ok(())
}
// ── .ini management ────────────────────────────────────────────────────────
/// Back up .ini files from the Config\Windows folder.
pub fn backup_ini_files(config_path: &Path, backup_root: &Path) -> Result<BackupResult> {
let ini_files: Vec<PathBuf> = fs::read_dir(config_path)
.with_context(|| format!("cannot read {}", config_path.display()))?
.flatten()
.filter(|e| {
e.file_name()
.to_string_lossy()
.ends_with(".ini")
})
.map(|e| e.path())
.collect();
if ini_files.is_empty() {
anyhow::bail!("no .ini files found in {}", config_path.display());
}
let ts = Local::now().format("%Y-%m-%d_%H%M%S");
let dest = backup_root.join(format!("ini_backup_{ts}"));
fs::create_dir_all(&dest)
.with_context(|| format!("cannot create ini backup dir {}", dest.display()))?;
let mut copied = 0usize;
let mut total = 0u64;
for f in &ini_files {
let meta = fs::metadata(f)?;
total += meta.len();
let name = f.file_name().unwrap();
fs::copy(f, dest.join(name))?;
copied += 1;
}
let verified = verify_backup(config_path, &dest);
Ok(BackupResult {
files_copied: copied,
total_size: total,
dest_dir: dest,
verified,
})
}
/// Restore .ini files from a backup into the Config\Windows folder.
pub fn restore_ini_files(
backup_dir: &Path,
config_path: &Path,
backup_root: &Path,
) -> Result<()> {
// Pre-restore safety
let ts = Local::now().format("%Y-%m-%d_%H%M%S");
let pre_restore = backup_root.join(format!("ini_pre_restore_{ts}"));
fs::create_dir_all(&pre_restore)?;
// Back up current .ini files
if let Ok(entries) = fs::read_dir(config_path) {
for entry in entries.flatten() {
let name = entry.file_name();
if name.to_string_lossy().ends_with(".ini") {
fs::copy(entry.path(), pre_restore.join(&name)).ok();
}
}
}
// Copy all files from backup → config_path
for entry in fs::read_dir(backup_dir)? {
let entry = entry?;
fs::copy(entry.path(), config_path.join(entry.file_name()))?;
}
Ok(())
}
/// Delete all .ini files from the Config\Windows folder.
///
/// **Guarded**: refuses to delete unless at least one `ini_backup_*` exists
/// in the backup root.
pub fn delete_ini_files(config_path: &Path, backup_root: &Path) -> Result<usize> {
// Guard: check for existing backup
let has_backup = backup_root.exists()
&& fs::read_dir(backup_root)
.map(|entries| {
entries.flatten().any(|e| {
e.file_name()
.to_string_lossy()
.starts_with("ini_backup_")
})
})
.unwrap_or(false);
if !has_backup {
anyhow::bail!(
"no .ini backup found — create a backup first via 'Manage Config > Backup'"
);
}
let mut deleted = 0usize;
for entry in fs::read_dir(config_path)? {
let entry = entry?;
if entry.file_name().to_string_lossy().ends_with(".ini") {
fs::remove_file(entry.path())?;
deleted += 1;
}
}
Ok(deleted)
}
/// List existing full backups in the backup root.
pub fn list_full_backups(backup_root: &Path) -> Vec<PathBuf> {
list_subdirs(backup_root, "notalterra_copy_")
}
/// List existing .ini backups in the backup root.
pub fn list_ini_backups(backup_root: &Path) -> Vec<PathBuf> {
list_subdirs(backup_root, "ini_backup_")
}
/// List .bak files in a save folder, sorted by mtime descending.
pub fn list_bak_files(save_folder: &Path) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = fs::read_dir(save_folder)
.into_iter()
.flatten()
.flatten()
.filter(|e| {
e.file_name()
.to_string_lossy()
.ends_with(".bak")
})
.map(|e| e.path())
.collect();
// Sort by mtime descending
files.sort_by(|a, b| {
let ma = fs::metadata(a).ok();
let mb = fs::metadata(b).ok();
match (ma.and_then(|m| m.modified().ok()), mb.and_then(|m| m.modified().ok())) {
(Some(a), Some(b)) => b.cmp(&a),
_ => std::cmp::Ordering::Equal,
}
});
files
}
/// Enriched .bak file entry with GVAS metadata for the picker UI.
#[derive(Debug, Clone)]
pub struct BakFileSummary {
pub path: PathBuf,
pub filename: String,
pub slot: String,
pub display_name: Option<String>,
pub is_online: bool,
pub size: u64,
pub mtime: Option<String>,
}
/// List .bak files with parsed GVAS metadata.
///
/// Each file is read to extract `SlotName` and `DisplayName`. Files that
/// fail to parse still appear — the metadata fields are simply empty.
pub fn list_bak_files_with_meta(save_folder: &Path) -> Vec<BakFileSummary> {
use chrono::TimeZone;
let mut files: Vec<BakFileSummary> = Vec::new();
let entries: Vec<_> = fs::read_dir(save_folder)
.into_iter()
.flatten()
.flatten()
.filter(|e| {
e.file_name()
.to_string_lossy()
.ends_with(".bak")
})
.collect();
for entry in entries {
let path = entry.path();
let filename = entry.file_name().to_string_lossy().to_string();
let meta = fs::metadata(&path).ok();
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
let mtime = meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| {
let secs = t
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
Local
.timestamp_opt(secs as i64, 0)
.single()
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
});
// Use filename-derived slot for grouping (authoritative).
// Internal SlotName is advisory — files may have been moved.
let slot = derive_slot_from_filename(&filename).unwrap_or_else(|| "?".into());
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);
files.push(BakFileSummary {
path,
filename,
slot,
display_name,
is_online,
size,
mtime,
});
}
// Sort by mtime descending, then by slot
files.sort_by(|a, b| {
a.slot
.cmp(&b.slot)
.then_with(|| b.mtime.cmp(&a.mtime))
});
files
}
/// Keep only the most recent .bak per slot, discarding older versioned backups.
pub fn dedup_by_slot(files: Vec<BakFileSummary>) -> Vec<BakFileSummary> {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut out = Vec::new();
for f in files {
if seen.insert(f.slot.clone()) {
out.push(f);
}
}
out
}
/// Scan the save folder and return stats for the dashboard.
pub fn folder_stats(
save_folder: Option<&Path>,
backup_root: &Path,
) -> (usize, usize, bool) {
let (live, bak) = if let Some(dir) = save_folder {
if let Ok(entries) = fs::read_dir(dir) {
let mut l = 0;
let mut b = 0;
for e in entries.flatten() {
let name = e.file_name();
let name_str = name.to_string_lossy();
if name_str.ends_with(".sav") {
l += 1;
} else if name_str.ends_with(".bak") {
b += 1;
}
}
(l, b)
} else {
(0, 0)
}
} else {
(0, 0)
};
let ini = backup_root.exists()
&& fs::read_dir(backup_root)
.map(|entries| {
entries.flatten().any(|e| {
e.file_name()
.to_string_lossy()
.starts_with("ini_backup_")
})
})
.unwrap_or(false);
(live, bak, ini)
}
// ── internal helpers ───────────────────────────────────────────────────────
/// Copy only files matching `savegame_*` prefix from `src` to `dest`.
fn copy_save_files(
src: &Path,
dest: &Path,
count: &mut usize,
total_size: &mut u64,
) -> Result<()> {
fs::create_dir_all(dest)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
let meta = entry.metadata()?;
if meta.is_dir() {
continue;
}
if meta.is_file() && name_str.starts_with("savegame_") {
let dest_path = dest.join(&name);
fs::copy(&entry.path(), &dest_path)?;
*count += 1;
*total_size += meta.len();
}
}
Ok(())
}
fn copy_recursive(
src: &Path,
dest: &Path,
count: &mut usize,
total_size: &mut u64,
) -> Result<()> {
fs::create_dir_all(dest)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let name = entry.file_name();
let dest_path = dest.join(&name);
let meta = entry.metadata()?;
if meta.is_dir() {
copy_recursive(&src_path, &dest_path, count, total_size)?;
} else if meta.is_file() {
fs::copy(&src_path, &dest_path)?;
*count += 1;
*total_size += meta.len();
}
}
Ok(())
}
/// Verify that files in `src` match those in `dest` (by relative path + size).
fn verify_backup(src: &Path, dest: &Path) -> bool {
let Ok(src_entries) = fs::read_dir(src) else { return false };
for entry in src_entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("savegame_") {
continue;
}
let expected = dest.join(&name);
if !expected.exists() {
return false;
}
if let (Ok(sm), Ok(dm)) = (entry.metadata(), fs::metadata(&expected)) {
if sm.len() != dm.len() {
return false;
}
}
}
true
}
fn list_subdirs(root: &Path, prefix: &str) -> Vec<PathBuf> {
if !root.exists() {
return Vec::new();
}
let mut dirs: Vec<PathBuf> = fs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with(prefix)
&& e.path().is_dir()
})
.map(|e| e.path())
.collect();
dirs.sort_by(|a, b| {
let ma = fs::metadata(a).map(|m| m.modified().ok()).ok().flatten();
let mb = fs::metadata(b).map(|m| m.modified().ok()).ok().flatten();
match (ma, mb) {
(Some(a), Some(b)) => b.cmp(&a),
_ => std::cmp::Ordering::Equal,
}
});
dirs
}
+675
View File
@@ -0,0 +1,675 @@
//! Modern terminal UI built on ratatui + crossterm.
//!
//! Design principles:
//! - Dashboard layout: header bar, main panel, status line
//! - Keyboard-first: arrow keys + Enter/Esc, no mouse dependency
//! - Semantic color: cyan=info, green=success, yellow=warning, red=error
//! - Adapts to terminal size; minimum 60×15
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph},
Frame,
};
use std::time::Instant;
// ── app state ──────────────────────────────────────────────────────────────
/// Global application state passed through every frame.
pub struct AppState {
/// Terminal dimensions (updated on Resize events)
pub cols: u16,
pub rows: u16,
/// Current save-folder path (for the header bar)
pub save_path: Option<String>,
/// Number of live .sav files in the current folder
pub live_save_count: usize,
/// Number of .bak backup files
pub backup_count: usize,
/// Whether a .ini backup exists
pub has_ini_backup: bool,
/// Version string for the header
pub version: String,
/// Last operation result (for the status bar)
pub status_message: Option<String>,
pub status_style: StatusStyle,
/// Spinner state
pub spinner_active: bool,
pub spinner_start: Option<Instant>,
}
#[derive(Clone, Copy, PartialEq)]
pub enum StatusStyle {
Info,
Success,
Warning,
Error,
Neutral,
}
impl Default for AppState {
fn default() -> Self {
Self {
cols: 80,
rows: 24,
save_path: None,
live_save_count: 0,
backup_count: 0,
has_ini_backup: false,
version: String::new(),
status_message: None,
status_style: StatusStyle::Neutral,
spinner_active: false,
spinner_start: None,
}
}
}
// ── public rendering entry points ──────────────────────────────────────────
/// Draw the main menu. Hides the locate item when saves are already found.
pub fn draw_main_menu(f: &mut Frame, state: &mut ListState, app: &AppState, save_found: bool) {
let items: Vec<&str> = if save_found {
vec![
" Recover .sav file from .bak",
" Create full backup",
" Restore full backup",
" Inspect save files",
" Manage UE5 Config (.ini) files",
" View disclaimer",
" Exit",
]
} else {
vec![
" Locate Subnautica save files",
" Recover .sav file from .bak",
" Create full backup",
" Restore full backup",
" Inspect save files",
" Manage UE5 Config (.ini) files",
" View disclaimer",
" Exit",
]
};
let descs: Vec<&str> = if save_found {
vec![
"Restore a .sav file from its .bak backup",
"Copy the savegame files to NotAlterra_Backups",
"Restore a full backup from NotAlterra_Backups",
"View detailed GVAS metadata for each save file",
"Backup, restore, or delete .ini files in Config/Windows",
"Re-read the disclaimer and terms of use",
"Close NotAlterra",
]
} else {
vec![
"Scan all drives for Subnautica 2 save folders",
"Restore a .sav file from its .bak backup",
"Copy the savegame files to NotAlterra_Backups",
"Restore a full backup from NotAlterra_Backups",
"View detailed GVAS metadata for each save file",
"Backup, restore, or delete .ini files in Config/Windows",
"Re-read the disclaimer and terms of use",
"Close NotAlterra",
]
};
let chunks = standard_layout(f.area(), items.len());
draw_header(f, chunks[0], app);
draw_status_dashboard(f, chunks[1], app);
let prompt = "↑/↓ navigate Enter select";
draw_select_list(f, chunks[2], &items, &descs, prompt, state);
draw_status_bar(f, chunks[3], app);
}
/// Draw the disclaimer popup with full warning text.
pub fn draw_disclaimer_popup(f: &mut Frame, _app: &AppState, selected_yes: bool) {
let popup_w = 60.min(f.area().width.saturating_sub(4));
let popup_h = 18.min(f.area().height.saturating_sub(4));
let area = centered_rect_size(popup_w, popup_h, f.area());
f.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(Color::Yellow));
f.render_widget(block, area);
let inner = inner(area, 2, 1);
let lines = vec![
Line::from(Span::styled("DISCLAIMER", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))),
Line::from(""),
Line::from(Span::styled("This tool was created using an AI Agent. While", Style::default().fg(Color::White))),
Line::from(Span::styled("every effort has been made to ensure it works", Style::default().fg(Color::White))),
Line::from(Span::styled("correctly, you should review the code and test", Style::default().fg(Color::White))),
Line::from(Span::styled("on a backup before using it on live save files.", Style::default().fg(Color::White))),
Line::from(""),
Line::from(Span::styled("NotAlterra is not affiliated with Unknown Worlds", Style::default().fg(Color::DarkGray))),
Line::from(Span::styled("Entertainment or KRAFTON. Use at your own risk.", Style::default().fg(Color::DarkGray))),
Line::from(""),
Line::from(Span::styled("The author is NOT responsible for any data loss.", Style::default().fg(Color::White).add_modifier(Modifier::BOLD))),
];
f.render_widget(Paragraph::new(lines).alignment(Alignment::Center), Rect { height: 11, ..inner });
let yes_style = if selected_yes { Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Green) };
let no_style = if !selected_yes { Style::default().fg(Color::Black).bg(Color::Red).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Red) };
let buttons = Line::from(vec![Span::styled("[ Accept ]", yes_style), Span::raw(" "), Span::styled("[ Decline ]", no_style)]);
f.render_widget(Paragraph::new(buttons).alignment(Alignment::Center), Rect { y: inner.y + 12, height: 1, ..inner });
}
/// Draw a simple confirmation popup with [ Yes ] [ No ] buttons.
pub fn draw_confirm_popup(
f: &mut Frame,
_app: &AppState,
title: &str,
details: &[(&str, &str)],
selected_yes: bool,
) {
let max_w = details.iter().map(|(k, v)| k.len() + v.len() + 4).max().unwrap_or(20).max(30) as u16;
let popup_w = (max_w + 4).min(f.area().width.saturating_sub(4));
let popup_h = (details.len() as u16 + 6).min(f.area().height.saturating_sub(4));
let area = centered_rect_size(popup_w, popup_h, f.area());
f.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(Color::Yellow));
f.render_widget(block, area);
let inner = inner(area, 2, 1);
// Title
f.render_widget(
Paragraph::new(Span::styled(title, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)))
.alignment(Alignment::Center),
Rect { height: 1, ..inner },
);
// Details
let detail_lines: Vec<Line> = details.iter().map(|(k, v)| {
let icon = if k.starts_with('⚠') { Color::Yellow } else { Color::Gray };
Line::from(vec![
Span::styled(format!("{k}: "), Style::default().fg(icon)),
Span::styled(*v, Style::default()),
])
}).collect();
f.render_widget(
Paragraph::new(detail_lines),
Rect { y: inner.y + 2, height: details.len() as u16, ..inner },
);
// Yes / No buttons
let yes_style = if selected_yes { Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD) }
else { Style::default().fg(Color::Green) };
let no_style = if !selected_yes { Style::default().fg(Color::Black).bg(Color::Red).add_modifier(Modifier::BOLD) }
else { Style::default().fg(Color::Red) };
let buttons = Line::from(vec![
Span::styled("[ Yes ]", yes_style),
Span::raw(" "),
Span::styled("[ No ]", no_style),
]);
f.render_widget(
Paragraph::new(buttons).alignment(Alignment::Center),
Rect { y: inner.y + inner.height.saturating_sub(1), height: 1, ..inner },
);
}
pub fn draw_ok_dialog(f: &mut Frame, _app: &AppState, title: &str, message: &str) {
let content_w = message.lines().map(|l| l.len()).max().unwrap_or(20).max(title.len()) as u16 + 6;
let popup_w = content_w.max(50).min(f.area().width.saturating_sub(4));
let popup_h = (message.lines().count() as u16 + 7).min(f.area().height.saturating_sub(4));
let area = centered_rect_size(popup_w, popup_h, f.area());
f.render_widget(Clear, area);
let block = Block::default().borders(Borders::ALL).border_type(BorderType::Plain).border_style(Style::default().fg(Color::Cyan));
f.render_widget(block, area);
let inner = inner(area, 2, 1);
f.render_widget(Paragraph::new(Span::styled(title, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))).alignment(Alignment::Center), Rect { height: 1, ..inner });
let msg_h = message.lines().count() as u16;
f.render_widget(Paragraph::new(message.to_string()).style(Style::default().fg(Color::Gray)).alignment(Alignment::Left), Rect { x: inner.x + 2, y: inner.y + 2, width: inner.width.saturating_sub(4), height: msg_h });
let ok = Span::styled("[ OK ]", Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD));
f.render_widget(Paragraph::new(ok).alignment(Alignment::Center), Rect { y: inner.y + inner.height.saturating_sub(2), height: 1, ..inner });
}
pub fn draw_ok_dialog_styled(f: &mut Frame, _app: &AppState, title: &str, lines: &[Line]) {
let content_w = lines.iter().map(|l| l.width() as u16).max().unwrap_or(20).max(title.len() as u16) + 10;
let popup_w = content_w.max(50).min(f.area().width.saturating_sub(4));
let popup_h = (lines.len() as u16 + 7).min(f.area().height.saturating_sub(4));
let area = centered_rect_size(popup_w, popup_h, f.area());
f.render_widget(Clear, area);
let block = Block::default().borders(Borders::ALL).border_type(BorderType::Plain).border_style(Style::default().fg(Color::Cyan));
f.render_widget(block, area);
let inner = inner(area, 2, 1);
f.render_widget(Paragraph::new(Span::styled(title, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))).alignment(Alignment::Center), Rect { height: 1, ..inner });
f.render_widget(Paragraph::new(lines.to_vec()).style(Style::default()).alignment(Alignment::Left), Rect { x: inner.x + 2, y: inner.y + 2, width: inner.width.saturating_sub(4), height: lines.len() as u16 });
let ok = Span::styled("[ OK ]", Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD));
f.render_widget(Paragraph::new(ok).alignment(Alignment::Center), Rect { y: inner.y + inner.height.saturating_sub(2), height: 1, ..inner });
}
fn centered_rect_size(w: u16, h: u16, r: Rect) -> Rect {
let popup = Layout::default().direction(Direction::Vertical)
.constraints([Constraint::Length((r.height.saturating_sub(h))/2), Constraint::Length(h), Constraint::Length((r.height.saturating_sub(h))/2)])
.split(r);
Layout::default().direction(Direction::Horizontal)
.constraints([Constraint::Length((r.width.saturating_sub(w))/2), Constraint::Length(w), Constraint::Length((r.width.saturating_sub(w))/2)])
.split(popup[1])[1]
}
/// Draw a sub-menu (e.g. Config management).
pub fn draw_sub_menu(
f: &mut Frame,
app: &AppState,
title: &str,
items: &[&str],
descs: &[&str],
state: &mut ListState,
) {
let chunks = standard_layout(f.area(), items.len());
draw_header(f, chunks[0], app);
let title_p = Paragraph::new(Span::styled(
title,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
f.render_widget(title_p, chunks[1]);
draw_select_list(f, chunks[2], items, descs, "↑/↓ navigate Enter select Esc back", state);
draw_status_bar(f, chunks[3], app);
}
/// Draw a simple text screen with a "press any key" prompt.
pub fn draw_text_screen(
f: &mut Frame,
app: &AppState,
lines: &[Line],
prompt: &str,
) {
let chunks = standard_layout(f.area(), lines.len());
draw_header(f, chunks[0], app);
f.render_widget(Paragraph::new(lines.to_vec()), chunks[2]);
let prompt_p = Paragraph::new(Span::styled(
prompt,
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Center);
f.render_widget(prompt_p, chunks[3]);
}
/// Draw a file/folder picker list.
pub fn draw_picker(
f: &mut Frame,
app: &AppState,
items: &[&str],
descs: &[&str],
state: &mut ListState,
) {
draw_picker_with_info(f, app, items, descs, state, None);
}
/// Draw a file/folder picker list with an extra selected-item info line
/// (e.g. showing the full filename of the highlighted .bak file).
pub fn draw_picker_with_info(
f: &mut Frame,
app: &AppState,
items: &[&str],
descs: &[&str],
state: &mut ListState,
selected_info: Option<&str>,
) {
let chunks = standard_layout(f.area(), items.len());
draw_header(f, chunks[0], app);
let prompt = "↑/↓ navigate Enter select Esc cancel";
draw_select_list_with_info(f, chunks[2], items, descs, prompt, state, selected_info);
draw_status_bar(f, chunks[3], app);
}
// ── internal drawing helpers ───────────────────────────────────────────────
fn standard_layout(area: Rect, menu_items: usize) -> Vec<Rect> {
let menu_height = menu_items as u16 + 4; // items + gaps + prompt line
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // header
Constraint::Length(2), // dashboard
Constraint::Min(menu_height.min(area.height.saturating_sub(6))), // menu
Constraint::Length(1), // status bar
])
.split(area).to_vec()
}
fn draw_header(f: &mut Frame, area: Rect, app: &AppState) {
let header_block = Block::default()
.borders(Borders::BOTTOM)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(Color::Cyan));
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(20),
Constraint::Min(0),
])
.split(inner(area, 1, 0));
let title_line = Line::from(vec![
Span::styled("NOTALTERRA", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(app.version.clone(), Style::default().fg(Color::DarkGray)),
]);
f.render_widget(Paragraph::new(title_line), chunks[0]);
let path_line = if let Some(ref path) = app.save_path {
let max_w = chunks[1].width.saturating_sub(2) as usize;
let display = truncate_path_tail(path, max_w);
Paragraph::new(Span::styled(display, Style::default().fg(Color::Gray)))
.alignment(Alignment::Right)
} else {
Paragraph::new(Span::styled(
"no save folder selected",
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Right)
};
f.render_widget(path_line, chunks[1]);
f.render_widget(header_block, area);
}
fn draw_status_dashboard(f: &mut Frame, area: Rect, app: &AppState) {
let live = Span::styled(
format!(" Save: {} ", if app.save_path.is_some() { app.live_save_count.to_string() } else { "".into() }),
Style::default().fg(Color::Green),
);
let bak = Span::styled(
format!(" Backups: {} ", app.backup_count),
Style::default().fg(Color::Yellow),
);
let ini = Span::styled(
format!(" .ini backup: {} ", if app.has_ini_backup { "yes" } else { "no" }),
Style::default().fg(if app.has_ini_backup { Color::Green } else { Color::DarkGray }),
);
let line = Line::from(vec![
Span::raw(" "),
live,
Span::raw(" "),
bak,
Span::raw(" "),
ini,
]);
f.render_widget(Paragraph::new(line), area);
}
fn draw_select_list(
f: &mut Frame,
area: Rect,
items: &[&str],
descs: &[&str],
prompt: &str,
state: &mut ListState,
) {
let list_area = Rect {
height: area.height.saturating_sub(1),
..area
};
let list_items: Vec<ListItem> = items
.iter()
.map(|item| {
ListItem::new(Span::raw(*item))
.style(Style::default())
})
.collect();
let list = List::new(list_items)
.highlight_style(
Style::default()
.bg(Color::Cyan)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(" ")
.repeat_highlight_symbol(true);
f.render_stateful_widget(list, list_area, state);
// Description line for the highlighted item
let desc_idx = state.selected().unwrap_or(0).min(descs.len().saturating_sub(1));
let desc = descs.get(desc_idx).copied().unwrap_or("");
let desc_line = Paragraph::new(Span::styled(
format!(" {desc}"),
Style::default().fg(Color::DarkGray),
));
f.render_widget(
desc_line,
Rect {
x: area.x,
y: area.y + area.height.saturating_sub(1),
width: area.width,
height: 1,
},
);
// Prompt at bottom-right
let prompt_len = prompt.len() as u16;
if area.width > prompt_len + 2 {
let prompt_p = Paragraph::new(Span::styled(
prompt,
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Right);
f.render_widget(
prompt_p,
Rect {
x: area.x,
y: area.y + area.height.saturating_sub(1),
width: area.width.saturating_sub(2),
height: 1,
},
);
}
}
fn draw_select_list_with_info(
f: &mut Frame,
area: Rect,
items: &[&str],
descs: &[&str],
prompt: &str,
state: &mut ListState,
selected_info: Option<&str>,
) {
let extra = if selected_info.is_some() { 1u16 } else { 0u16 };
let list_area = Rect {
height: area.height.saturating_sub(1 + extra),
..area
};
let list_items: Vec<ListItem> = items
.iter()
.map(|item| {
ListItem::new(Span::raw(*item))
.style(Style::default())
})
.collect();
let list = List::new(list_items)
.highlight_style(
Style::default()
.bg(Color::Cyan)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(" ")
.repeat_highlight_symbol(true);
f.render_stateful_widget(list, list_area, state);
// Description line for the highlighted item
let base_y = area.y + area.height.saturating_sub(1 + extra);
let desc_idx = state.selected().unwrap_or(0).min(descs.len().saturating_sub(1));
let desc = descs.get(desc_idx).copied().unwrap_or("");
let desc_line = Paragraph::new(Span::styled(
format!(" {desc}"),
Style::default().fg(Color::DarkGray),
));
f.render_widget(
desc_line,
Rect {
x: area.x,
y: base_y,
width: area.width,
height: 1,
},
);
// Selected-item info line (e.g. filename)
if let Some(info) = selected_info {
let info_line = Paragraph::new(Span::styled(
format!("{info}"),
Style::default().fg(Color::White),
));
f.render_widget(
info_line,
Rect {
x: area.x,
y: base_y + 1,
width: area.width,
height: 1,
},
);
}
// Prompt at bottom-right
let prompt_len = prompt.len() as u16;
if area.width > prompt_len + 2 {
let prompt_p = Paragraph::new(Span::styled(
prompt,
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Right);
f.render_widget(
prompt_p,
Rect {
x: area.x,
y: area.y + area.height.saturating_sub(1),
width: area.width.saturating_sub(2),
height: 1,
},
);
}
}
fn draw_status_bar(f: &mut Frame, area: Rect, app: &AppState) {
if let Some(ref msg) = app.status_message {
let color = match app.status_style {
StatusStyle::Success => Color::Green,
StatusStyle::Warning => Color::Yellow,
StatusStyle::Error => Color::Red,
StatusStyle::Info => Color::Cyan,
StatusStyle::Neutral => Color::Gray,
};
let icon = match app.status_style {
StatusStyle::Success => "",
StatusStyle::Warning => "!",
StatusStyle::Error => "×",
StatusStyle::Info => "i",
StatusStyle::Neutral => " ",
};
let line = Line::from(vec![
Span::styled(
format!(" [{icon}] {msg}"),
Style::default().fg(color),
),
]);
f.render_widget(Paragraph::new(line), area);
}
// Spinner
if app.spinner_active {
if let Some(start) = app.spinner_start {
let elapsed = start.elapsed().as_millis() as u64;
let frames = &["", "", "", "", "", "", "", "", "", ""];
let idx = ((elapsed / 80) % frames.len() as u64) as usize;
let spinner = Paragraph::new(Span::styled(
frames[idx],
Style::default().fg(Color::Cyan),
));
f.render_widget(
spinner,
Rect {
x: area.width.saturating_sub(4),
y: area.y,
width: 3,
height: 1,
},
);
}
}
}
/// Truncate a path to show the tail (most specific directories).
/// e.g. `C:\Users\...\Subnautica2\Saved\SaveGames` → `…\Subnautica2\Saved\SaveGames`
fn truncate_path_tail(path: &str, max_width: usize) -> String {
if path.len() <= max_width {
return path.to_string();
}
let keep = max_width.saturating_sub(3);
if keep == 0 {
return "".to_string();
}
let tail = &path[path.len().saturating_sub(keep)..];
// Walk forward to a path separator so we don't split mid-component
if let Some(sep_pos) = tail.find(&['\\', '/'][..]) {
let start = path.len().saturating_sub(keep) + sep_pos;
format!("{}", &path[start..])
} else {
format!("{}", tail)
}
}
/// Create a centered rectangle for modal overlays.
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
/// Shrink a rect by a margin on all sides.
fn inner(rect: Rect, margin_x: u16, margin_y: u16) -> Rect {
Rect {
x: rect.x + margin_x,
y: rect.y + margin_y,
width: rect.width.saturating_sub(margin_x * 2),
height: rect.height.saturating_sub(margin_y * 2),
}
}