mirror of
https://github.com/forkless/NotAlterra.git
synced 2026-08-16 08:36:37 +02:00
Initial public release
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Cross-compilation targets for NotAlterra
|
||||
#
|
||||
# Requires: sudo apt install mingw-w64 (Debian/Ubuntu)
|
||||
# sudo dnf install mingw64-gcc (Fedora)
|
||||
#
|
||||
# Build:
|
||||
# cargo build --release --target x86_64-pc-windows-gnu
|
||||
# cargo build --release # Linux native
|
||||
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
@@ -0,0 +1,6 @@
|
||||
/target
|
||||
/NotAlterra_Backups
|
||||
/samples
|
||||
*.log
|
||||
config.ini
|
||||
.deepseek
|
||||
Generated
+1293
File diff suppressed because it is too large
Load Diff
+26
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "notalterra"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
authors = ["NotAlterra"]
|
||||
license = "MIT"
|
||||
description = "Subnautica 2 save-file manager — cross-platform terminal application"
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
nom = "7"
|
||||
chrono = "0.4"
|
||||
dirs = "5"
|
||||
sysinfo = "0.33"
|
||||
regex = "1"
|
||||
anyhow = "1"
|
||||
log = "0.4"
|
||||
simplelog = "0.12"
|
||||
unicode-width = "0.2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 2
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 NotAlterra
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,106 @@
|
||||
# NotAlterra
|
||||
|
||||
**Subnautica 2 save-file manager — cross-platform terminal application.**
|
||||
|
||||
NotAlterra is an unofficial tool for managing Subnautica 2 save files. It locates your save folders, backs up and restores `.sav` / `.bak` files, and lets you inspect the internal metadata of every save.
|
||||
|
||||
Not affiliated with Unknown Worlds Entertainment or KRAFTON.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Auto-locate** save folders across Steam, Xbox, Epic, and custom installs
|
||||
- **Recover** a corrupted `.sav` from its `.bak` backup with rollback
|
||||
- **Create / restore** full backups (only `savegame_*` files)
|
||||
- **Manage** UE5 Config `.ini` files — backup, restore, delete
|
||||
- **Inspect** any `.sav` or `.bak` file — full GVAS metadata dump
|
||||
- **Slot grouping** with Online / Local detection
|
||||
- **Warns** on name changes and online↔local mode switches before recovery
|
||||
- **Game-running guard** — exits or warns if Subnautica 2 is active
|
||||
- **Transaction log** — all actions timestamped to `transaction.log`
|
||||
- **Cross-platform** — Linux and Windows console builds
|
||||
|
||||
---
|
||||
|
||||
## How to Build
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
cargo build --release
|
||||
|
||||
# Windows (from Linux with mingw-w64)
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
sudo apt install mingw-w64
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
Run the binary. On first launch it auto-scans for your save folder. The menu is keyboard-driven:
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `↑` `↓` | Navigate |
|
||||
| `Enter` | Select |
|
||||
| `Esc` | Cancel / Back |
|
||||
| `Y` / `N` | Accept / Decline in dialogs |
|
||||
|
||||
### Menu
|
||||
|
||||
1. **Recover .sav from .bak** — pick a backup, preview metadata, overwrite the live save
|
||||
2. **Create full backup** — copies all `savegame_*` files to `NotAlterra_Backups`
|
||||
3. **Restore full backup** — overwrite the save folder from a previous backup
|
||||
4. **Inspect save files** — view all GVAS properties of any `.sav` / `.bak`
|
||||
5. **Manage UE5 Config (.ini) files** — backup, restore, or delete `.ini` files
|
||||
6. **View disclaimer**
|
||||
7. **Exit**
|
||||
|
||||
---
|
||||
|
||||
## Where Files Live
|
||||
|
||||
```
|
||||
%LOCALAPPDATA%\Subnautica2\Saved\SaveGames\
|
||||
savegame_0.sav
|
||||
savegame_0.bak
|
||||
savegame_0_1.bak
|
||||
savegame_1.sav
|
||||
...
|
||||
```
|
||||
|
||||
Backups are stored in `NotAlterra_Backups\` alongside the binary.
|
||||
|
||||
---
|
||||
|
||||
## config.ini
|
||||
|
||||
Created automatically next to the binary:
|
||||
|
||||
```ini
|
||||
[alterra]
|
||||
last_path = C:\Users\...\Subnautica2\Saved\SaveGames
|
||||
last_scan = 2026-05-31 18:00:00
|
||||
disclaimer_accepted = true
|
||||
config_path = C:\Users\...\Subnautica2\Saved\Config\Windows
|
||||
```
|
||||
|
||||
Delete `config.ini` to force a fresh scan on next launch.
|
||||
|
||||
---
|
||||
|
||||
## Safety
|
||||
|
||||
- Runs in your user context — no admin privileges required
|
||||
- No network connections
|
||||
- Read-only inspect mode won't touch files
|
||||
- Pre-restore snapshots created automatically
|
||||
- Guard against file corruption: refuses overwrite if Subnautica 2 is running
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see `LICENSE.md`.
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# NotAlterra release build script
|
||||
# Produces: target/release/notalterra (Linux)
|
||||
# target/x86_64-pc-windows-gnu/release/notalterra.exe (Windows)
|
||||
|
||||
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
echo "=== NotAlterra v${VERSION} ==="
|
||||
|
||||
# Ensure Windows target is installed
|
||||
if ! rustup target list --installed | grep -q x86_64-pc-windows-gnu; then
|
||||
echo "Installing Windows target..."
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
fi
|
||||
|
||||
# Check for mingw linker
|
||||
if ! command -v x86_64-w64-mingw32-gcc &>/dev/null; then
|
||||
echo "WARNING: mingw-w64 not found — Windows build will be skipped."
|
||||
echo "Install it: sudo apt install mingw-w64"
|
||||
WINDOWS_SKIP=1
|
||||
else
|
||||
WINDOWS_SKIP=0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Building Linux release..."
|
||||
cargo build --release
|
||||
|
||||
if [ "$WINDOWS_SKIP" -eq 0 ]; then
|
||||
echo ""
|
||||
echo "Building Windows release..."
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Build complete ==="
|
||||
echo ""
|
||||
ls -lh target/release/notalterra
|
||||
if [ "$WINDOWS_SKIP" -eq 0 ]; then
|
||||
ls -lh target/x86_64-pc-windows-gnu/release/notalterra.exe
|
||||
fi
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Dump GVAS metadata for all .sav/.bak files in `samples/`.
|
||||
//! Run: cargo run --example dump_samples
|
||||
|
||||
use chrono::TimeZone;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let dir = Path::new("samples");
|
||||
let mut entries: Vec<_> = fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
let s = e.file_name().to_string_lossy();
|
||||
s.ends_with(".sav") || s.ends_with(".bak")
|
||||
})
|
||||
.collect();
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
let ma = a.metadata().ok().and_then(|m| m.modified().ok());
|
||||
let mb = b.metadata().ok().and_then(|m| m.modified().ok());
|
||||
mb.cmp(&ma)
|
||||
.then_with(|| a.file_name().cmp(&b.file_name()))
|
||||
});
|
||||
|
||||
println!(
|
||||
"{:<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 &entries {
|
||||
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 secs = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
|
||||
chrono::Local
|
||||
.timestamp_opt(secs as i64, 0)
|
||||
.single()
|
||||
})
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string());
|
||||
|
||||
let meta = notalterra::gvas::extract_metadata(&path).ok();
|
||||
let slot = meta.as_ref().and_then(|m| m.slot_name.clone()).unwrap_or_else(|| {
|
||||
notalterra::gvas::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 is_online = meta.map(|m| m.is_online).unwrap_or(false);
|
||||
|
||||
let label_num = slot.strip_prefix("savegame_").unwrap_or(&slot);
|
||||
let first = seen.insert(slot.clone());
|
||||
let label = if first { format!("Slot {label_num}") } else { String::new() };
|
||||
|
||||
let typ = if is_online { "Online" } else { "Local" };
|
||||
let sz = if size < 1024 {
|
||||
format!("{size} B")
|
||||
} else if size < 1024 * 1024 {
|
||||
format!("{:.0} KB", size as f64 / 1024.0)
|
||||
} else {
|
||||
format!("{:.1} MB", size as f64 / (1024.0 * 1024.0))
|
||||
};
|
||||
|
||||
println!(
|
||||
"{label:<8} {display:<26} {typ:<6} {sz:>7} {:<19} {name:<28}",
|
||||
mtime.as_deref().unwrap_or("?"),
|
||||
);
|
||||
}
|
||||
}
|
||||
+138
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+530
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user