From 5496a3415bb21f74b9b3bbb62a0f675193d9d92f Mon Sep 17 00:00:00 2001 From: forkless Date: Sun, 31 May 2026 18:34:09 +0200 Subject: [PATCH] Initial public release --- .cargo/config.toml | 11 + .gitignore | 6 + Cargo.lock | 1293 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 26 + LICENSE.md | 21 + README.md | 106 ++++ build.sh | 42 ++ examples/dump_samples.rs | 77 +++ src/config.rs | 138 ++++ src/discovery.rs | 371 +++++++++++ src/guard.rs | 104 +++ src/gvas.rs | 462 ++++++++++++++ src/main.rs | 1182 ++++++++++++++++++++++++++++++++++ src/ops.rs | 530 ++++++++++++++++ src/tui.rs | 675 ++++++++++++++++++++ 15 files changed, 5044 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE.md create mode 100644 README.md create mode 100755 build.sh create mode 100644 examples/dump_samples.rs create mode 100644 src/config.rs create mode 100644 src/discovery.rs create mode 100644 src/guard.rs create mode 100644 src/gvas.rs create mode 100644 src/main.rs create mode 100644 src/ops.rs create mode 100644 src/tui.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..8d0af39 --- /dev/null +++ b/.cargo/config.toml @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..719bdbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +/NotAlterra_Backups +/samples +*.log +config.ini +.deepseek diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7989bf0 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1293 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notalterra" +version = "0.3.0" +dependencies = [ + "anyhow", + "chrono", + "crossterm", + "dirs", + "log", + "nom", + "ratatui", + "regex", + "simplelog", + "sysinfo", + "unicode-width 0.2.0", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simplelog" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" +dependencies = [ + "log", + "termcolor", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sysinfo" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a630e8c --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..204747d --- /dev/null +++ b/LICENSE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d4008fe --- /dev/null +++ b/README.md @@ -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`. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..a81ac36 --- /dev/null +++ b/build.sh @@ -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 diff --git a/examples/dump_samples.rs b/examples/dump_samples.rs new file mode 100644 index 0000000..e9a75e5 --- /dev/null +++ b/examples/dump_samples.rs @@ -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("?"), + ); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..a6814a3 --- /dev/null +++ b/src/config.rs @@ -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, + /// Last-known Config\Windows folder + pub config_path: Option, + /// Timestamp of last successful scan + pub last_scan: Option, + /// 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 { + 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> { + 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) +} diff --git a/src/discovery.rs b/src/discovery.rs new file mode 100644 index 0000000..0d6e7cb --- /dev/null +++ b/src/discovery.rs @@ -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 { + let mut found: Vec = Vec::new(); + let mut seen: std::collections::HashSet = 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, + seen: &mut std::collections::HashSet, +) { + 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, + seen: &mut std::collections::HashSet, +) { + // 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, + seen: &mut std::collections::HashSet, +) { + 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, + seen: &mut std::collections::HashSet, +) { + 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, + seen: &mut std::collections::HashSet, +) { + use std::collections::VecDeque; + + let mut queue: VecDeque = 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/guard.rs b/src/guard.rs new file mode 100644 index 0000000..1e6cc18 --- /dev/null +++ b/src/guard.rs @@ -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::() { + 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 { + // 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 +} diff --git a/src/gvas.rs b/src/gvas.rs new file mode 100644 index 0000000..d9b4764 --- /dev/null +++ b/src/gvas.rs @@ -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 { + 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 { + 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: `` +/// Returns (string, new_offset) or (None, offset) on failure. +fn read_fname(data: &[u8], offset: usize) -> (Option, 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: `` – 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, 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 = 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 { + 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 { + 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 { + 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, + pub display_name: Option, + pub is_online: bool, + pub was_multiplayer: bool, + pub game_mode: Option, + pub level_name: Option, + pub build_number: Option, + pub build_branch: Option, + pub saves_count: Option, + pub latest_version: Option, + pub data_version: Option, +} + +/// Parse a `.sav` or `.bak` file and return all known GVAS metadata. +pub fn extract_full_metadata(path: &Path) -> Result { + 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, + /// Human-readable display name entered in-game + pub display_name: Option, + /// Current online/multiplayer status (bIsMultiplayerSave) + pub is_online: bool, + /// Any extraction errors (non-fatal) + pub errors: Vec, +} + +/// Parse a `.sav` or `.bak` file and return its GVAS metadata. +pub fn extract_metadata(path: &Path) -> Result { + 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 { + 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 { + 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()); + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..46ee871 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1182 @@ +//! NotAlterra — Subnautica 2 save-file manager. +//! +//! Cross-platform terminal application. Locates save folders, recovers +//! .sav files from .bak backups, creates/restores full backups, and +//! manages Config (.ini) files. +//! +//! MIT License. Not affiliated with Unknown Worlds Entertainment or KRAFTON. + + + +mod config; +mod discovery; +mod guard; +mod gvas; +mod ops; +mod tui; + +use anyhow::Result; +use chrono::TimeZone; +use config::AppConfig; +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::{Backend, CrosstermBackend}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, ListState, Paragraph}, + Frame, Terminal, +}; +use std::io; +use std::path::{Path, PathBuf}; + +const VERSION: &str = concat!("v", env!("CARGO_PKG_VERSION")); + +fn main() -> Result<()> { + // ── setup terminal ───────────────────────────────────────────────── + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let result = run_app(&mut terminal); + + // ── teardown ─────────────────────────────────────────────────────── + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + result +} + +// ── app state ────────────────────────────────────────────────────────────── + +struct App { + config: AppConfig, + config_path: PathBuf, + log_path: PathBuf, + save_folder: Option, + tui_state: tui::AppState, +} + +impl App { + fn new() -> Result { + let config_path = crate::config::config_path(); + let config = crate::config::load_config(&config_path)?; + let log_path = guard::log_path(); + + let save_folder = config.last_path.as_deref().map(PathBuf::from); + + let mut tui_state = tui::AppState::default(); + tui_state.version = VERSION.to_string(); + tui_state.save_path = save_folder.as_ref().map(|p| p.display().to_string()); + + // Refresh stats for the dashboard + refresh_stats(&mut tui_state, save_folder.as_deref()); + + Ok(Self { + config, + config_path, + log_path, + save_folder, + tui_state, + }) + } + + fn backup_root(&self) -> PathBuf { + exe_dir().join("NotAlterra_Backups") + } + + fn set_status(&mut self, msg: &str, style: tui::StatusStyle) { + self.tui_state.status_message = Some(msg.to_string()); + self.tui_state.status_style = style; + } + + fn clear_status(&mut self) { + self.tui_state.status_message = None; + } + + fn set_spinner(&mut self, active: bool) { + self.tui_state.spinner_active = active; + if active { + self.tui_state.spinner_start = Some(std::time::Instant::now()); + } + } +} + +fn exe_dir() -> PathBuf { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn refresh_stats(tui_state: &mut tui::AppState, save_folder: Option<&Path>) { + tui_state.save_path = save_folder.map(|p| p.display().to_string()); + let backup_root = exe_dir().join("NotAlterra_Backups"); + let (live, bak, ini) = ops::folder_stats(save_folder, &backup_root); + tui_state.live_save_count = live; + tui_state.backup_count = bak; + tui_state.has_ini_backup = ini; +} + +// ── main loop ────────────────────────────────────────────────────────────── + +fn run_app(terminal: &mut Terminal) -> Result<()> { + let mut app = App::new()?; + + // Guard: exit if game is running + if guard::game_running() { + println!("Subnautica 2 is running. Close the game and relaunch NotAlterra."); + return Ok(()); + } + + // Disclaimer flow + if !app.config.disclaimer_accepted { + if !run_disclaimer(terminal, &mut app)? { + return Ok(()); // declined + } + } + + // Auto-scan on startup if no cached path + let save_found = if app.save_folder.is_some() { + true + } else { + app.set_spinner(true); + terminal.draw(|f| { + tui::draw_text_screen(f, &app.tui_state, + &[Line::from(Span::styled("Locating save folder…", Style::default().add_modifier(Modifier::BOLD)))], + "", + ); + })?; + let folders = discovery::discover_save_folders(); + app.set_spinner(false); + if let Some(first) = folders.first() { + app.save_folder = Some(first.path.clone()); + app.config.last_path = Some(first.path.display().to_string()); + app.config.last_scan = Some(chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string()); + crate::config::save_config(&app.config_path, &app.config)?; + refresh_stats(&mut app.tui_state, app.save_folder.as_deref()); + true + } else { + false + } + }; + + // Main menu loop + let mut menu_state = ListState::default().with_selected(Some(0)); + + loop { + terminal.draw(|f| { + let cols = f.area().width; + let rows = f.area().height; + if cols < 60 || rows < 15 { + draw_too_small(f); + return; + } + tui::draw_main_menu(f, &mut menu_state, &app.tui_state, save_found); + })?; + + let max_idx = if save_found { 6 } else { 7 }; + if event::poll(std::time::Duration::from_millis(250))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Release { continue; } + match key.code { + KeyCode::Up => { + let i = menu_state.selected().unwrap_or(0); + menu_state.select(Some(i.saturating_sub(1))); + } + KeyCode::Down => { + let i = menu_state.selected().unwrap_or(0); + menu_state.select(Some((i + 1).min(max_idx))); + } + KeyCode::Enter => { + let idx = menu_state.selected().unwrap_or(0); + // Remap: when locate is hidden, shift by 1 + let remap = if save_found { idx + 1 } else { idx }; + match remap { + 0 => action_locate_saves(terminal, &mut app)?, + 1 => action_recover_bak(terminal, &mut app)?, + 2 => action_create_backup(terminal, &mut app)?, + 3 => action_restore_backup(terminal, &mut app)?, + 4 => action_inspect_saves(terminal, &mut app)?, + 5 => run_ini_submenu(terminal, &mut app)?, + 6 => { + if !run_disclaimer(terminal, &mut app)? { + return Ok(()); // revoked → exit + } + } + 7 => return Ok(()), // Exit + _ => {} + } + app.clear_status(); + } + KeyCode::Esc => { + return Ok(()); + } + _ => {} + } + } + } + } +} + +// ── disclaimer ───────────────────────────────────────────────────────────── + +fn run_disclaimer(terminal: &mut Terminal, app: &mut App) -> Result { + let mut selected_yes = true; + loop { + terminal.draw(|f| { + if f.area().width < 60 || f.area().height < 20 { + draw_too_small(f); + return; + } + tui::draw_disclaimer_popup(f, &app.tui_state, selected_yes); + })?; + let key = read_key_event()?; + match key.code { + KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down => selected_yes = !selected_yes, + KeyCode::Char('y') | KeyCode::Char('Y') => { + guard::log_action("LICENSE", "accepted", "OK", &app.log_path)?; + app.config.disclaimer_accepted = true; + crate::config::save_config(&app.config_path, &app.config)?; + return Ok(true); + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + guard::log_action("LICENSE", "declined", "OK", &app.log_path)?; + app.config.disclaimer_accepted = false; + crate::config::save_config(&app.config_path, &app.config)?; + return Ok(false); + } + KeyCode::Enter => { + guard::log_action("LICENSE", if selected_yes { "accepted" } else { "declined" }, "OK", &app.log_path)?; + let accepted = selected_yes; + app.config.disclaimer_accepted = accepted; + crate::config::save_config(&app.config_path, &app.config)?; + return Ok(accepted); + } + _ => {} + } + } +} + +// ── menu actions ─────────────────────────────────────────────────────────── + +fn action_locate_saves(terminal: &mut Terminal, app: &mut App) -> Result<()> { + // Run scan on a background thread so we can show a live elapsed timer. + let (tx, rx) = std::sync::mpsc::channel(); + let scan_start = std::time::Instant::now(); + std::thread::spawn(move || { + let result = discovery::discover_save_folders(); + tx.send(result).ok(); + }); + + app.set_spinner(true); + let folders = loop { + let elapsed = scan_start.elapsed().as_secs(); + terminal.draw(|f| { + tui::draw_text_screen( + f, + &app.tui_state, + &[ + Line::from(Span::styled( + "Scanning for Subnautica save files…", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + "The first scan checks every user profile and common", + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + "install location — it may take a moment.", + Style::default().fg(Color::DarkGray), + )), + Line::from(""), + Line::from(Span::styled( + format!(" ⏱ {elapsed}s elapsed"), + Style::default().fg(Color::Cyan), + )), + ], + "", + ); + })?; + + if let Ok(f) = rx.try_recv() { + break f; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + }; + app.set_spinner(false); + + let elapsed = scan_start.elapsed(); + let elapsed_str = if elapsed.as_secs() >= 1 { + format!("{:.1}s", elapsed.as_secs_f64()) + } else { + format!("{}ms", elapsed.as_millis()) + }; + + if folders.is_empty() { + app.set_status("No Subnautica 2 save folders detected.", tui::StatusStyle::Error); + wait_for_key(terminal, app)?; + return Ok(()); + } + + // Cache the first result + if let Some(first) = folders.first() { + app.save_folder = Some(first.path.clone()); + app.config.last_path = Some(first.path.display().to_string()); + app.config.last_scan = Some(chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string()); + crate::config::save_config(&app.config_path, &app.config)?; + } + + refresh_stats(&mut app.tui_state, app.save_folder.as_deref()); + + let msg = format!("Found {} save folder(s) in {elapsed_str}.", folders.len()); + app.set_status(&msg, tui::StatusStyle::Success); + + // Show found folders + let lines: Vec = std::iter::once(Line::from(Span::styled( + format!("Found {} save folder(s):", folders.len()), + Style::default().fg(Color::Green), + ))) + .chain(folders.iter().map(|f| { + Line::from(Span::styled( + format!(" {} — {}", f.label, f.path.display()), + Style::default().fg(Color::Gray), + )) + })) + .collect(); + + let prompt = "Press any key to return to menu…"; + loop { + terminal.draw(|f| tui::draw_text_screen(f, &app.tui_state, &lines, prompt))?; + if let Event::Key(_) = event::read()? { + break; + } + } + + Ok(()) +} + +fn action_recover_bak(terminal: &mut Terminal, app: &mut App) -> Result<()> { + let save_folder = ensure_save_folder(terminal, app)?; + + // Warn if game is running + if guard::game_running() { + app.set_status( + "Subnautica 2 is running — save files may be locked!", + tui::StatusStyle::Warning, + ); + } + + // Show spinner while parsing metadata + app.set_status("Reading save metadata…", tui::StatusStyle::Info); + app.set_spinner(true); + terminal.draw(|f| { + tui::draw_text_screen( + f, + &app.tui_state, + &[Line::from(Span::styled( + "Reading save file metadata…", + Style::default().add_modifier(Modifier::BOLD), + ))], + "Parsing GVAS headers for slot names and display names…", + ); + })?; + + let bak_summaries = ops::list_bak_files_with_meta(&save_folder); + app.set_spinner(false); + app.clear_status(); + + if bak_summaries.is_empty() { + app.set_status("No .bak files found.", tui::StatusStyle::Error); + wait_for_key(terminal, app)?; + return Ok(()); + } + + // Build multi-column display with slot grouping. + // First entry in each slot gets a numbered label matching the savegame slot. + let mut labelled: std::collections::HashSet = std::collections::HashSet::new(); + let items: Vec = bak_summaries + .iter() + .map(|s| { + let num = slot_number(&s.slot); + let first = labelled.insert(s.slot.clone()); + let label_col = if first { format!("Slot {num}") } else { String::new() }; + let name = s.display_name.as_deref().unwrap_or("(unnamed)"); + let name_col = if name.len() > 24 { + format!("{}…", &name[..23]) + } else { + name.to_string() + }; + let date = s.mtime.as_deref().unwrap_or("?"); + let save_type = if s.is_online { "Online" } else { "Local" }; + format!( + " {:<8} {:<26} {:<6} {:>6} {}", + label_col, + name_col, + save_type, + format_size(s.size), + date, + ) + }) + .collect(); + let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect(); + + // Filenames for the info bar, and descriptions + let filenames: Vec = bak_summaries + .iter() + .map(|s| s.filename.clone()) + .collect(); + let descs: Vec = bak_summaries + .iter() + .map(|_| "Restore this backup to its canonical .sav file".to_string()) + .collect(); + let desc_refs: Vec<&str> = descs.iter().map(|s| s.as_str()).collect(); + let mut state = ListState::default().with_selected(Some(0)); + + loop { + let selected_info = state + .selected() + .and_then(|i| filenames.get(i)) + .map(|s| s.as_str()); + + terminal.draw(|f| { + tui::draw_picker_with_info( + f, + &app.tui_state, + &item_refs, + &desc_refs, + &mut state, + selected_info, + ); + })?; + let key = read_key_event()?; + match key.code { + KeyCode::Up => { + let i = state.selected().unwrap_or(0); + state.select(Some(i.saturating_sub(1))); + } + KeyCode::Down => { + let i = state.selected().unwrap_or(0); + state.select(Some((i + 1).min(bak_summaries.len().saturating_sub(1)))); + } + KeyCode::Enter => { + let idx = state.selected().unwrap_or(0); + let chosen = &bak_summaries[idx]; + let target = derive_target_sav(&chosen.filename); + let target_path = save_folder.join(&target); + + // Target .sav metadata + let target_meta = if target_path.exists() { + std::fs::metadata(&target_path).ok().map(|m| { + let sz = m.len(); + let mt = 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() + }); + let meta = crate::gvas::extract_metadata(&target_path).ok(); + let online = meta.as_ref().map(|m| m.is_online).unwrap_or(false); + let disp = meta.and_then(|m| m.display_name); + (sz, mt, online, disp) + }) + } else { None }; + + // Mode change warning + let mode_entry = target_meta.as_ref().and_then(|(_, _, live, _)| { + if *live != chosen.is_online { + let from = if *live { "Online" } else { "Local" }; + let to = if chosen.is_online { "Online" } else { "Local" }; + Some(("⚠ Mode change", format!("{from} → {to}"))) + } else { None } + }); + // Name change warning + let name_entry = target_meta.as_ref().and_then(|(_, _, _, live_name)| { + let bak_name = chosen.display_name.as_deref().unwrap_or("(unnamed)"); + match live_name { + Some(live) if live != bak_name => { + Some(("⚠ Name change", format!("{live} → {bak_name}"))) + } + _ => None, + } + }); + + // Build details + let src_line = format!("{} {} {}", chosen.filename, format_size(chosen.size), chosen.mtime.as_deref().unwrap_or("?")); + let mut details = vec![ + ("Slot", chosen.slot.as_str()), + ("Name", chosen.display_name.as_deref().unwrap_or("(unnamed)")), + ("Backup", src_line.as_str()), + ]; + let tgt_line: String; + if let Some((sz, mt, _, _)) = &target_meta { + tgt_line = format!("{} {} {}", target, format_size(*sz), mt.map(|d| d.format("%Y-%m-%d %H:%M").to_string()).as_deref().unwrap_or("?")); + details.push(("Replace", tgt_line.as_str())); + } else { + details.push(("Create", target.as_str())); + } + if let Some((k, v)) = &mode_entry { + details.push((&**k, &**v)); + } + if let Some((k, v)) = &name_entry { + details.push((&**k, &**v)); + } + if !has_existing_backup(app) { + details.push(("⚠ No backup", "create a full backup first")); + } + let accepted = confirm_modal(terminal, app, "Confirm Recovery", &details)?; + + if accepted { + match ops::recover_bak_to_sav(&save_folder, &chosen.filename) { + Ok(result) => { + let msg = format!("Restored {} → {}", result.source, result.target); + app.set_status(&msg, tui::StatusStyle::Success); + guard::log_action( + "RECOVER", + &format!("{} → {}", result.source, result.target), + "OK", + &app.log_path, + )?; + } + Err(e) => { + app.set_status(&format!("Failed: {e}"), tui::StatusStyle::Error); + guard::log_action( + "RECOVER", + &chosen.filename, + &format!("FAILED: {e}"), + &app.log_path, + )?; + } + } + } + break; + } + KeyCode::Esc => break, + _ => {} + } + } + + refresh_stats(&mut app.tui_state, Some(&save_folder)); + Ok(()) +} + +fn action_create_backup(terminal: &mut Terminal, app: &mut App) -> Result<()> { + let save_folder = ensure_save_folder(terminal, app)?; + + if guard::game_running() { + app.set_status("Subnautica 2 is running — files may be locked.", tui::StatusStyle::Warning); + } + + app.set_status("Creating backup…", tui::StatusStyle::Info); + app.set_spinner(true); + terminal.draw(|f| { + tui::draw_text_screen( + f, + &app.tui_state, + &[Line::from(Span::styled( + "Creating full backup…", + Style::default().add_modifier(Modifier::BOLD), + ))], + "Copying save folder contents…", + ); + })?; + + let backup_root = app.backup_root(); + match ops::create_full_backup(&save_folder, &backup_root) { + Ok(result) => { + app.set_spinner(false); + let verified = if result.verified { "verified" } else { "unverified" }; + let msg = format!( + "{} files, {} — backup {}", + result.files_copied, + format_size(result.total_size), + verified, + ); + guard::log_action("MANUAL_BAK", &msg, "OK", &app.log_path)?; + ok_dialog(terminal, app, "Backup Complete", &msg)?; + } + Err(e) => { + app.set_spinner(false); + let msg = format!("Backup failed: {e}"); + guard::log_action("MANUAL_BAK", &save_folder.display().to_string(), &format!("FAILED: {e}"), &app.log_path)?; + ok_dialog(terminal, app, "Backup Failed", &msg)?; + } + } + Ok(()) +} + +fn action_restore_backup(terminal: &mut Terminal, app: &mut App) -> Result<()> { + let save_folder = ensure_save_folder(terminal, app)?; + let backup_root = app.backup_root(); + + let backups = ops::list_full_backups(&backup_root); + if backups.is_empty() { + ok_dialog(terminal, app, "No Backups", "No full backups found in NotAlterra_Backups.\nUse 'Create full backup' from the main menu first.")?; + return Ok(()); + } + + let items: Vec = backups + .iter() + .map(|p| { + let name = p.file_name().unwrap().to_string_lossy(); + format!(" {name}") + }) + .collect(); + let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect(); + let descs: Vec<&str> = vec!["Restore this backup into the save folder"; backups.len()]; + let mut state = ListState::default().with_selected(Some(0)); + + loop { + terminal.draw(|f| { + tui::draw_picker(f, &app.tui_state, &item_refs, &descs, &mut state); + })?; + let key = read_key_event()?; + match key.code { + KeyCode::Up => { + let i = state.selected().unwrap_or(0); + state.select(Some(i.saturating_sub(1))); + } + KeyCode::Down => { + let i = state.selected().unwrap_or(0); + state.select(Some((i + 1).min(backups.len().saturating_sub(1)))); + } + KeyCode::Enter => { + let idx = state.selected().unwrap_or(0); + let chosen = &backups[idx]; + let name = chosen.file_name().unwrap().to_string_lossy().to_string(); + + let mut restore_details = vec![("Backup", name.as_str())]; + if !has_existing_backup(app) { + restore_details.push(("⚠ No backup", "create a full backup first")); + } + let accepted = confirm_modal(terminal, app, "Confirm Restore", &restore_details)?; + + if accepted { + guard::log_action("AUTO_BAK", &format!("pre-restore → {}", save_folder.display()), "OK", &app.log_path)?; + match ops::restore_full_backup(chosen, &save_folder, &backup_root) { + Ok(()) => { + app.set_status("Restore complete. Previous files preserved.", tui::StatusStyle::Success); + guard::log_action("RESTORE", &format!("{} → {}", name, save_folder.display()), "OK", &app.log_path)?; + } + Err(e) => { + app.set_status(&format!("Restore failed: {e}"), tui::StatusStyle::Error); + guard::log_action("RESTORE", &name, &format!("FAILED: {e}"), &app.log_path)?; + } + } + } + break; + } + KeyCode::Esc => break, + _ => {} + } + } + + refresh_stats(&mut app.tui_state, Some(&save_folder)); + Ok(()) +} + +// ── .ini submenu ─────────────────────────────────────────────────────────── + +fn run_ini_submenu(terminal: &mut Terminal, app: &mut App) -> Result<()> { + let config_path = get_config_path(terminal, app)?; + let backup_root = app.backup_root(); + + let items: Vec<&str> = vec![ + "Backup .ini files", + "Restore .ini files from backup", + "Delete .ini files (requires backup)", + ]; + let descs: Vec<&str> = vec![ + "Copy all .ini files from Config/Windows to NotAlterra_Backups", + "Restore .ini files from a previous backup", + "Remove .ini files — game regenerates defaults (backup required first)", + ]; + let mut state = ListState::default().with_selected(Some(0)); + + loop { + terminal.draw(|f| { + tui::draw_sub_menu(f, &app.tui_state, "Config (.ini) Management", &items, &descs, &mut state); + })?; + let key = read_key_event()?; + match key.code { + KeyCode::Up => { + let i = state.selected().unwrap_or(0); + state.select(Some(i.saturating_sub(1))); + } + KeyCode::Down => { + let i = state.selected().unwrap_or(0); + state.select(Some((i + 1).min(2))); + } + KeyCode::Enter => { + let idx = state.selected().unwrap_or(0); + match idx { + 0 => ini_backup_action(terminal, app, &config_path, &backup_root)?, + 1 => ini_restore_action(terminal, app, &config_path, &backup_root)?, + 2 => ini_delete_action(terminal, app, &config_path, &backup_root)?, + _ => {} + } + } + KeyCode::Esc => break, + _ => {} + } + } + + refresh_stats(&mut app.tui_state, app.save_folder.as_deref()); + Ok(()) +} + +fn ini_backup_action( + terminal: &mut Terminal, + app: &mut App, + config_path: &Path, + backup_root: &Path, +) -> Result<()> { + if guard::game_running() { + app.set_status("Subnautica 2 is running — files may be locked.", tui::StatusStyle::Warning); + } + + match ops::backup_ini_files(config_path, backup_root) { + Ok(result) => { + let verified = if result.verified { "verified" } else { "unverified" }; + app.set_status( + &format!("Config backup created: {} files ({})", result.files_copied, verified), + tui::StatusStyle::Success, + ); + guard::log_action("CONFIG_BAK", &result.dest_dir.display().to_string(), "OK", &app.log_path)?; + refresh_stats(&mut app.tui_state, app.save_folder.as_deref()); + } + Err(e) => { + app.set_status(&format!("Config backup failed: {e}"), tui::StatusStyle::Error); + } + } + + wait_for_key(terminal, app)?; + Ok(()) +} + +fn ini_restore_action( + terminal: &mut Terminal, + app: &mut App, + config_path: &Path, + backup_root: &Path, +) -> Result<()> { + let backups = ops::list_ini_backups(backup_root); + if backups.is_empty() { + ok_dialog(terminal, app, "No .ini Backups", "No .ini backups found.\nUse 'Backup .ini files' first.")?; + return Ok(()); + } + + let items: Vec = backups + .iter() + .map(|p| { + let name = p.file_name().unwrap().to_string_lossy(); + format!(" {name}") + }) + .collect(); + let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect(); + let descs: Vec<&str> = vec!["Restore this .ini backup"; backups.len()]; + let mut state = ListState::default().with_selected(Some(0)); + + loop { + terminal.draw(|f| { + tui::draw_picker(f, &app.tui_state, &item_refs, &descs, &mut state); + })?; + let key = read_key_event()?; + match key.code { + KeyCode::Up => { + let i = state.selected().unwrap_or(0); + state.select(Some(i.saturating_sub(1))); + } + KeyCode::Down => { + let i = state.selected().unwrap_or(0); + state.select(Some((i + 1).min(backups.len().saturating_sub(1)))); + } + KeyCode::Enter => { + let idx = state.selected().unwrap_or(0); + let chosen = &backups[idx]; + + guard::log_action("AUTO_BAK", &format!("ini pre-restore → {}", config_path.display()), "OK", &app.log_path)?; + match ops::restore_ini_files(chosen, config_path, backup_root) { + Ok(()) => { + guard::log_action("CONFIG_RESTORE", &chosen.display().to_string(), "OK", &app.log_path)?; + ok_dialog(terminal, app, ".ini Restore Complete", ".ini files restored.")?; + } + Err(e) => { + ok_dialog(terminal, app, ".ini Restore Failed", &format!("{e}"))?; + } + } + break; + } + KeyCode::Esc => break, + _ => {} + } + } + + Ok(()) +} + +fn ini_delete_action( + terminal: &mut Terminal, + app: &mut App, + config_path: &Path, + backup_root: &Path, +) -> Result<()> { + match ops::delete_ini_files(config_path, backup_root) { + Ok(n) => { + let msg = format!("Deleted {n} .ini file(s).\nThe game will regenerate defaults on next launch."); + guard::log_action("CONFIG_DEL", &config_path.display().to_string(), "OK", &app.log_path)?; + ok_dialog(terminal, app, ".ini Delete Complete", &msg)?; + } + Err(e) => { + ok_dialog(terminal, app, ".ini Delete Refused", &format!("{e}"))?; + } + } + Ok(()) +} + +// ── inspect saves ────────────────────────────────────────────────────────── + +fn action_inspect_saves(terminal: &mut Terminal, app: &mut App) -> Result<()> { + let save_folder = ensure_save_folder(terminal, app)?; + let mut files: Vec<_> = std::fs::read_dir(&save_folder) + .into_iter() + .flatten() + .flatten() + .filter(|e| { + let n = e.file_name(); + let s = n.to_string_lossy(); + s.starts_with("savegame_") && (s.ends_with(".sav") || s.ends_with(".bak")) + }) + .collect(); + files.sort_by_key(|e| e.file_name()); + + let mut labelled = std::collections::HashSet::new(); + let items: Vec = files.iter().map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + let slot = crate::gvas::derive_slot_from_filename(&name).unwrap_or_else(|| "?".into()); + let num = slot_number(&slot); + let first = labelled.insert(slot.clone()); + let label = if first { format!("Slot {num}") } else { String::new() }; + let sz = e.metadata().map(|m| m.len()).unwrap_or(0); + format!(" {:<8} {:<30} {:>7}", label, name, format_size(sz)) + }).collect(); + let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect(); + let filenames: Vec = files.iter().map(|e| e.file_name().to_string_lossy().to_string()).collect(); + let descs = vec!["Press Enter to view full GVAS metadata"; files.len()]; + let desc_refs: Vec<&str> = descs.iter().map(|s| *s).collect(); + let mut state = ListState::default().with_selected(Some(0)); + + loop { + let selected_info = state.selected().and_then(|i| filenames.get(i)).map(|s| s.as_str()); + terminal.draw(|f| { + tui::draw_picker_with_info(f, &app.tui_state, &item_refs, &desc_refs, &mut state, selected_info); + })?; + let key = read_key_event()?; + match key.code { + KeyCode::Up => { let i = state.selected().unwrap_or(0); state.select(Some(i.saturating_sub(1))); } + KeyCode::Down => { let i = state.selected().unwrap_or(0); state.select(Some((i+1).min(files.len().saturating_sub(1)))); } + KeyCode::Enter => { + let idx = state.selected().unwrap_or(0); + let path = files[idx].path(); + match crate::gvas::extract_full_metadata(&path) { + Ok(meta) => { + let dim = Style::default().fg(Color::DarkGray); + let val = Style::default().fg(Color::White); + let hl = Style::default().fg(Color::Cyan); + let mut lines: Vec = vec![ + Line::from(Span::styled(filenames[idx].clone(), hl.add_modifier(Modifier::BOLD))), + Line::from(""), + ]; + let fields: Vec<(&str, &str, String)> = vec![ + ("SlotName", ":", meta.slot_name.as_deref().unwrap_or("?").into()), + ("DisplayName", ":", meta.display_name.as_deref().unwrap_or("(unnamed)").into()), + ("Online", ":", (if meta.is_online { "yes" } else { "no" }).into()), + ("Was Multi", ":", (if meta.was_multiplayer { "yes" } else { "no" }).into()), + ("GameMode", ":", meta.game_mode.as_deref().unwrap_or("?").into()), + ("Level", ":", meta.level_name.as_deref().unwrap_or("?").into()), + ("Build", ":", meta.build_number.map_or("?".into(), |n| n.to_string())), + ("Branch", ":", meta.build_branch.as_deref().unwrap_or("?").into()), + ("Saves", ":", meta.saves_count.map_or("?".into(), |n| n.to_string())), + ("Ver", ":", meta.latest_version.map_or("?".into(), |n| n.to_string())), + ("DataVer", ":", meta.data_version.map_or("?".into(), |n| n.to_string())), + ]; + let max_label: usize = fields.iter().map(|(k, _, _)| k.len()).max().unwrap_or(8); + for (key, sep, value) in fields { + let padded = format!("{: { + ok_dialog(terminal, app, "Parse Error", &format!("{e}"))?; + } + } + } + KeyCode::Esc => break, + _ => {} + } + } + Ok(()) +} + +// ── helpers ──────────────────────────────────────────────────────────────── + +/// Ensure we have a save folder — prompt discovery if not. +fn ensure_save_folder(_terminal: &mut Terminal, app: &mut App) -> Result { + // Try cached path first + if let Some(ref sf) = app.save_folder { + if sf.exists() { + return Ok(sf.clone()); + } + } + + // Discover + let folders = discovery::discover_save_folders(); + if let Some(first) = folders.first() { + app.save_folder = Some(first.path.clone()); + app.config.last_path = Some(first.path.display().to_string()); + crate::config::save_config(&app.config_path, &app.config)?; + refresh_stats(&mut app.tui_state, app.save_folder.as_deref()); + return Ok(first.path.clone()); + } + + anyhow::bail!("No save folders found. Run 'Locate Subnautica save files' from the main menu.") +} + +/// Get or discover the Config\Windows path. +fn get_config_path(_terminal: &mut Terminal, app: &mut App) -> Result { + // Try cached config path + if let Some(ref cp) = app.config.config_path { + let p = PathBuf::from(cp); + if p.exists() { + return Ok(p); + } + } + + // Derive from save folder + if let Some(ref sf) = app.save_folder { + if let Some(cp) = discovery::derive_config_path(sf) { + return Ok(cp); + } + } + + // Fall back to discovery + let folders = discovery::discover_save_folders(); + for f in &folders { + if let Some(cp) = discovery::derive_config_path(&f.path) { + app.config.config_path = Some(cp.display().to_string()); + crate::config::save_config(&app.config_path, &app.config)?; + return Ok(cp); + } + } + + anyhow::bail!("Cannot determine Config/Windows path. Run 'Locate Subnautica save files' first.") +} + +/// Derive the target .sav name from a .bak filename. +/// Read the next keyboard event, silently skipping KeyRelease events. +/// Key-repeat and initial press are both accepted. +fn read_key_event() -> Result { + loop { + if event::poll(std::time::Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + if key.kind != KeyEventKind::Release { + return Ok(key); + } + } + } + // Other events (terminal changes etc) trigger a loop → redraw + } +} + +/// Determine if a save folder is cloud-backed (Xbox/Game Pass wgs path). +fn is_cloud_path(path: &Path) -> bool { + let s = path.to_string_lossy().to_lowercase(); + s.contains("\\packages\\") || s.contains("/packages/") || s.contains("\\wgs") || s.contains("/wgs") +} + +/// Extract the numeric suffix from a slot name like "savegame_3". +fn slot_number(slot: &str) -> String { + slot.strip_prefix("savegame_") + .map(|s| s.to_string()) + .unwrap_or_else(|| slot.to_string()) +} + +fn ok_dialog_styled(terminal: &mut Terminal, app: &App, title: &str, lines: &[Line]) -> Result<()> { + loop { + terminal.draw(|f| tui::draw_ok_dialog_styled(f, &app.tui_state, title, lines))?; + let key = read_key_event()?; + if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) { + return Ok(()); + } + } +} + +fn ok_dialog(terminal: &mut Terminal, app: &App, title: &str, msg: &str) -> Result<()> { + loop { + terminal.draw(|f| tui::draw_ok_dialog(f, &app.tui_state, title, msg))?; + let key = read_key_event()?; + if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) { + return Ok(()); + } + } +} + +fn has_existing_backup(app: &App) -> bool { + let root = app.backup_root(); + root.exists() && std::fs::read_dir(&root).map_or(false, |mut d| d.any(|e| e.map_or(false, |e| e.path().is_dir()))) +} + +fn require_backup(terminal: &mut Terminal, app: &mut App) -> Result { + if has_existing_backup(app) { + return Ok(true); + } + app.set_status("No backup found — create a full backup before destructive actions.", tui::StatusStyle::Error); + wait_for_key(terminal, app)?; + Ok(false) +} + +fn derive_target_sav(bak_name: &str) -> String { + crate::gvas::derive_slot_from_filename(bak_name) + .map(|s| format!("{s}.sav")) + .unwrap_or_else(|| bak_name.replace(".bak", ".sav")) +} + +/// Show a confirmation popup with [ Yes ] [ No ] buttons. +fn confirm_modal( + terminal: &mut Terminal, + app: &mut App, + title: &str, + details: &[(&str, &str)], +) -> Result { + let mut selected_yes = true; + loop { + terminal.draw(|f| { + if f.area().width < 60 || f.area().height < 15 { + draw_too_small(f); + return; + } + tui::draw_confirm_popup(f, &app.tui_state, title, details, selected_yes); + })?; + let key = read_key_event()?; + match key.code { + KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down => { + selected_yes = !selected_yes; + } + KeyCode::Char('y') | KeyCode::Char('Y') => return Ok(true), + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => return Ok(false), + KeyCode::Enter => return Ok(selected_yes), + _ => {} + } + } +} + +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] +} + +/// Wait for any key press, showing the current state. +fn wait_for_key(terminal: &mut Terminal, app: &App) -> Result<()> { + loop { + terminal.draw(|f| { + let prompt_span = Span::styled( + "Press any key to return to menu…", + Style::default().fg(Color::DarkGray), + ); + let p = Paragraph::new(prompt_span) + .alignment(Alignment::Center); + f.render_widget(p, centered_bottom(f.area())); + // Also show the status message if set + if let Some(ref msg) = app.tui_state.status_message { + let color = match app.tui_state.status_style { + tui::StatusStyle::Success => Color::Green, + tui::StatusStyle::Warning => Color::Yellow, + tui::StatusStyle::Error => Color::Red, + tui::StatusStyle::Info => Color::Cyan, + tui::StatusStyle::Neutral => Color::Gray, + }; + let line = Line::from(Span::styled(format!(" [{msg}]"), Style::default().fg(color))); + f.render_widget( + Paragraph::new(line), + Rect { + x: f.area().x, + y: f.area().height.saturating_sub(2), + width: f.area().width, + height: 1, + }, + ); + } + })?; + if let Event::Key(_) = event::read()? { + break; + } + } + Ok(()) +} + +fn centered_bottom(area: Rect) -> Rect { + Rect { + x: area.x, + y: area.height.saturating_sub(1), + width: area.width, + height: 1, + } +} + +/// Draw "terminal too small" message. +fn draw_too_small(f: &mut Frame) { + let msg = "Terminal too small (min 60×15)"; + let p = Paragraph::new(Span::styled( + msg, + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::NONE)); + let area = centered_rect(50, 20, f.area()); + f.render_widget(p, area); +} + +/// Read file metadata. +fn fs_meta(path: &Path) -> Result { + std::fs::metadata(path).map_err(|_| ()) +} + +/// Format a byte size human-readably. +fn format_size(bytes: u64) -> String { + if bytes < 1024 { + format!("{bytes} B") + } else if bytes < 1024 * 1024 { + format!("{:.0} KB", bytes as f64 / 1024.0) + } else { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } +} diff --git a/src/ops.rs b/src/ops.rs new file mode 100644 index 0000000..312a0f3 --- /dev/null +++ b/src/ops.rs @@ -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, +} + +// ── .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 `.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 { + 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_`. +pub fn create_full_backup( + save_folder: &Path, + backup_root: &Path, +) -> Result { + 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 { + let ini_files: Vec = 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 { + // 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 { + list_subdirs(backup_root, "notalterra_copy_") +} + +/// List existing .ini backups in the backup root. +pub fn list_ini_backups(backup_root: &Path) -> Vec { + 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 { + let mut files: Vec = 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, + pub is_online: bool, + pub size: u64, + pub mtime: Option, +} + +/// 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 { + use chrono::TimeZone; + + let mut files: Vec = 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) -> Vec { + let mut seen: std::collections::HashSet = 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 { + if !root.exists() { + return Vec::new(); + } + let mut dirs: Vec = 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 +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..c638f1d --- /dev/null +++ b/src/tui.rs @@ -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, + /// 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, + pub status_style: StatusStyle, + /// Spinner state + pub spinner_active: bool, + pub spinner_start: Option, +} + +#[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 = 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 { + 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 = 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 = 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), + } +}