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
+11
View File
@@ -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"
+6
View File
@@ -0,0 +1,6 @@
/target
/NotAlterra_Backups
/samples
*.log
config.ini
.deepseek
Generated
+1293
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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
View File
@@ -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.
+106
View File
@@ -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`.
Executable
+42
View File
@@ -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
+77
View File
@@ -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
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),
}
}