Add Beat Saber data backup command

This commit is contained in:
pleb
2026-06-28 14:27:12 -07:00
parent 931c1d4f73
commit 7639fb7270
4 changed files with 639 additions and 30 deletions
+78 -9
View File
@@ -1,6 +1,6 @@
# plugin-helper # plugin-helper
`plugin-helper` is an early Python CLI for managing Beat Saber plugins in a mounted Windows BSManager install. `plugin-helper` is an early Python CLI for managing Beat Saber plugins in BSManager installs.
The first implementation focuses on safe local workflows: The first implementation focuses on safe local workflows:
@@ -11,28 +11,97 @@ The first implementation focuses on safe local workflows:
- apply exactly that plan and record install state - apply exactly that plan and record install state
- uninstall only files recorded in install state - uninstall only files recorded in install state
Default BSManager instance root: Default BSManager instance roots:
```text ```text
/home/pleb/Windows/Users/pleb/BSManager/BSInstances /home/pleb/Windows/Users/pleb/BSManager/BSInstances
/home/pleb/.local/share/BSManager/BSInstances
``` ```
Override with `--instances-root` or `PLUGIN_HELPER_INSTANCES_ROOT`. Override with `--instances-root` or `PLUGIN_HELPER_INSTANCES_ROOT`. To search
multiple explicit roots, separate them with `:`.
## Commands ## Commands
Run from the repo root with `PYTHONPATH=src` unless installed. For normal use, run the menu from the repo root. Use repo-local state so the
menu sees the same plans, downloads, and install records used by the helper
workflow:
```sh ```sh
PYTHONPATH=src python -m plugin_helper instances PYTHONPATH=src python -m plugin_helper --state-dir .state menu
PYTHONPATH=src python -m plugin_helper --state-dir .state installed --instance 1.40.8
PYTHONPATH=src python -m plugin_helper updates --instance 1.40.8
PYTHONPATH=src python -m plugin_helper scan --instance 1.40.8
PYTHONPATH=src python -m plugin_helper --state-dir .state plan --instance 1.40.8
``` ```
The individual subcommands are mostly for automation and debugging. If you use
them, pass `--state-dir .state` unless you intentionally want the default live
state outside this repo.
Install assets are currently expected to already exist locally, usually under: Install assets are currently expected to already exist locally, usually under:
```text ```text
.state/instances/<instance>/downloads/<plugin-id>/ .state/instances/<instance>/downloads/<plugin-id>/
``` ```
## Beat Saber Data Backups
`backup-userdata` copies the mounted Windows `UserData` folder and Beat Saber
Windows app data into this repo. With the Windows mount at `~/Windows`, the
helper infers Beat Saber's Windows app data as:
```text
/home/pleb/Windows/Users/pleb/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber
```
Example manual backup after mounting Windows:
```sh
PYTHONPATH=src python -m plugin_helper \
--instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances \
backup-userdata \
--instance 1.44.1
```
By default the repo receives plain copied files under `backups/beat-saber/UserData`
and `backups/beat-saber/AppData`, plus `backups/beat-saber/backup-descriptor.json`
describing the source paths from the latest backup run. Use
`--appdata-path <path>` if the Windows profile path ever differs, `--no-appdata`
for a `UserData`-only sync, or `--backup-root <path>` to choose a different
repo-local destination.
The backup intentionally omits bulky/generated data:
- `UserData/BeatLeader/Replays`
- `UserData/BeatLeader/ReplayerCache`
- `UserData/BeatLeader/LeaderboardsCache`
- `UserData/BeatLeader/ReplayHeadersCache`
- `UserData/ScoreSaber/Replays`
- `UserData/BeatSaberPlus/Cache`
- `UserData/BeatSaverNotifier.json`
- `UserData/Accsaber/PlayerScoreCache.json`
- `UserData/NalulunaAvatars/cache`
- `UserData/SongDetailsCache.proto`
- `AppData/com.unity.addressables`
- `*.log`
Other large folders seen in the 1.44.1 `UserData` tree are
`Custom Campaigns`, `SongCore`, `AssetBundleLoadingTools`, `NalulunaMenu`, and
`NalulunaSkybox`. Those are not skipped by default because they can contain
custom content or non-obvious user choices rather than pure cache data.
## Operational notes
- BSManager can inherit launch arguments configured in Steam for Beat Saber.
Check both places before debugging black screens or startup hangs. Duplicating
arguments such as `--no-yeet fpfc` can make the game fail command-line
parsing after BSIPA and plugins have already loaded.
- BSIPA is managed as a first-class bootstrap phase. The `bootstrap` command
applies the locked `bsipa` root archive, runs `IPA.exe -n` through Proton, and
records every bootstrap-relevant file under root `IPA.exe*`, `winhttp.dll`,
`Libs/`, and `IPA/`, including backups created during patching.
- If an instance lockfile includes `bsipa`, ordinary plugin plans require a
recorded bootstrap state plus a `Logs/_latest.log` that shows BSIPA startup.
Use `bootstrap-check` before planning a batch when you want a quick gate.
- Use [`docs/SMOKETEST.md`](docs/SMOKETEST.md) after installing or removing a
plugin batch. It documents the short Proton/BSManager launch loop, IPA log
checks, and teardown commands.
- The 1.44.1 migration tracker lives in
[`docs/notes/install-and-verify-plugins-1.44.1.md`](docs/notes/install-and-verify-plugins-1.44.1.md).
+206 -16
View File
@@ -4,9 +4,11 @@ import argparse
import json import json
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Callable
from .config import instances_root, repo_root, state_root from .config import instances_roots, repo_root, state_root
from .bootstrap import run_bootstrap
from .bsipa import check_bsipa_health
from .checker import check_lock from .checker import check_lock
from .github import fetch_releases from .github import fetch_releases
from .installer import apply_plan, uninstall_plugin from .installer import apply_plan, uninstall_plugin
@@ -17,7 +19,7 @@ from .planner import create_plan
from .scanner import scan_instance from .scanner import scan_instance
from .state import load_installed_state from .state import load_installed_state
from .updates import check_updates from .updates import check_updates
from .userdata import backup_userdata from .userdata import sync_windows_data_repo
def _json(data: Any) -> None: def _json(data: Any) -> None:
@@ -135,6 +137,12 @@ def build_parser() -> argparse.ArgumentParser:
parents=[_common_parent()], parents=[_common_parent()],
) )
subcommands.add_parser(
"menu",
help="Open an interactive instance/action menu",
parents=[_common_parent()],
)
scan = subcommands.add_parser( scan = subcommands.add_parser(
"scan", "scan",
help="Inspect installed Plugins, Libs, and IPA/Pending files", help="Inspect installed Plugins, Libs, and IPA/Pending files",
@@ -171,6 +179,25 @@ def build_parser() -> argparse.ArgumentParser:
check.add_argument("--lockfile") check.add_argument("--lockfile")
check.add_argument("--json", action="store_true", help="Print full JSON check output") check.add_argument("--json", action="store_true", help="Print full JSON check output")
bootstrap = subcommands.add_parser(
"bootstrap",
help="Install locked BSIPA, run IPA.exe -n through Proton, and record bootstrap files",
parents=[_common_parent()],
)
bootstrap.add_argument("--instance", required=True)
bootstrap.add_argument("--registry", default="registry/plugins.toml")
bootstrap.add_argument("--lockfile")
bootstrap.add_argument("--proton", help="Path to Proton executable")
bootstrap.add_argument("--json", action="store_true", help="Print full JSON bootstrap output")
bootstrap_check = subcommands.add_parser(
"bootstrap-check",
help="Verify recorded BSIPA bootstrap state and latest IPA log",
parents=[_common_parent()],
)
bootstrap_check.add_argument("--instance", required=True)
bootstrap_check.add_argument("--json", action="store_true", help="Print full JSON bootstrap health output")
updates = subcommands.add_parser( updates = subcommands.add_parser(
"updates", "updates",
help="Check GitHub for newer matching releases for locked plugins", help="Check GitHub for newer matching releases for locked plugins",
@@ -211,10 +238,13 @@ def build_parser() -> argparse.ArgumentParser:
backup = subcommands.add_parser( backup = subcommands.add_parser(
"backup-userdata", "backup-userdata",
help="Create a timestamped UserData backup archive", help="Copy UserData and Windows AppData into this repo",
parents=[_common_parent()], parents=[_common_parent()],
) )
backup.add_argument("--instance", required=True) backup.add_argument("--instance", required=True)
backup.add_argument("--backup-root", default="backups/beat-saber", help="Repo-local backup directory")
backup.add_argument("--appdata-path", help="Override Beat Saber Windows AppData path")
backup.add_argument("--no-appdata", action="store_true", help="Only copy UserData")
return parser return parser
@@ -225,17 +255,115 @@ def _common_parent() -> argparse.ArgumentParser:
return parent return parent
def _ask_choice(
*,
title: str,
choices: list[tuple[str, str] | tuple[str, str, str]],
input_func: Callable[[str], str] | None = None,
) -> str | None:
ask = input_func or input
print()
print(title)
for index, choice in enumerate(choices, start=1):
label = choice[1]
print(f" {index}. {label}")
if len(choice) > 2:
print(f" {choice[2]}")
print(" q. Quit")
while True:
answer = ask("> ").strip().lower()
if answer in {"q", "quit", "exit"}:
return None
if answer.isdigit():
index = int(answer)
if 1 <= index <= len(choices):
return choices[index - 1][0]
print("Choose a listed number, or q to quit.")
def _run_menu(inst_roots: list[Path], st_root: Path, input_func: Callable[[str], str] | None = None) -> int:
ask = input_func or input
instances = list_instances(inst_roots)
if not instances:
print(f"No Beat Saber instances found under {', '.join(str(root) for root in inst_roots)}")
return 1
instances_by_choice = {str(index): item for index, item in enumerate(instances, start=1)}
instance_choices = [(str(index), f"{item.name} {item.path}") for index, item in enumerate(instances, start=1)]
action_choices = [
("installed", "Show managed installs", "Lists plugins recorded in plugin-helper state with locked versions and files."),
("updates", "Check locked plugin updates", "Looks at GitHub releases for newer assets matching locked plugins."),
("scan", "Scan installed files", "Counts files currently present in Plugins/, Libs/, and IPA/Pending/."),
("check", "Check lockfile and assets", "Validates registry entries, lockfile data, local assets, and SHA-256 values."),
("bootstrap", "Bootstrap BSIPA", "Fetches the locked BSIPA archive, installs it, runs IPA.exe -n, and records bootstrap files."),
("bootstrap-check", "Check BSIPA bootstrap", "Verifies recorded bootstrap state and the latest BSIPA log evidence."),
("plan", "Create install plan", "Writes a dry-run JSON plan for locked plugin files before anything is applied."),
("apply", "Apply a plan by path", "Installs exactly the file changes from a previously generated plan JSON."),
("backup-userdata", "Back up UserData", "Creates a timestamped archive of UserData before risky changes."),
("change", "Choose another version", "Returns to the Beat Saber version picker."),
]
selected_instance_key = _ask_choice(
title="Choose Beat Saber version",
choices=instance_choices,
input_func=ask,
)
if selected_instance_key is None:
return 0
selected_instance = instances_by_choice[selected_instance_key]
while True:
selected_action = _ask_choice(
title=f"Choose action for {selected_instance.name}",
choices=action_choices,
input_func=ask,
)
if selected_action is None:
return 0
if selected_action == "change":
selected_instance_key = _ask_choice(
title="Choose Beat Saber version",
choices=instance_choices,
input_func=ask,
)
if selected_instance_key is None:
return 0
selected_instance = instances_by_choice[selected_instance_key]
continue
command = [
"--instances-root",
str(selected_instance.path.parent),
"--state-dir",
str(st_root),
selected_action,
]
if selected_action == "apply":
plan_path = ask("Plan path> ").strip()
if not plan_path:
print("No plan path entered.")
continue
command.append(plan_path)
else:
command.extend(["--instance", selected_instance.name])
print()
status = run(command)
print(f"Command exited with status {status}")
def run(argv: list[str] | None = None) -> int: def run(argv: list[str] | None = None) -> int:
parser = build_parser() parser = build_parser()
args = parser.parse_args(argv) args = parser.parse_args(argv)
inst_root = instances_root(getattr(args, "instances_root", None)) inst_roots = instances_roots(getattr(args, "instances_root", None))
st_root = state_root(getattr(args, "state_dir", None)) st_root = state_root(getattr(args, "state_dir", None))
try: try:
if args.command == "instances": if args.command == "instances":
found = list_instances(inst_root) found = list_instances(inst_roots)
if not found: if not found:
print(f"No Beat Saber instances found under {inst_root}") print(f"No Beat Saber instances found under {', '.join(str(root) for root in inst_roots)}")
return 1 return 1
for item in found: for item in found:
flags = [] flags = []
@@ -249,8 +377,11 @@ def run(argv: list[str] | None = None) -> int:
print(f"{item.name}\t{item.path}{suffix}") print(f"{item.name}\t{item.path}{suffix}")
return 0 return 0
if args.command == "menu":
return _run_menu(inst_roots, st_root)
if args.command == "scan": if args.command == "scan":
instance = get_instance(inst_root, args.instance) instance = get_instance(inst_roots, args.instance)
result = scan_instance(instance.path, include_hashes=args.hashes) result = scan_instance(instance.path, include_hashes=args.hashes)
if args.json: if args.json:
_json(result) _json(result)
@@ -331,8 +462,57 @@ def run(argv: list[str] | None = None) -> int:
print_updates(result) print_updates(result)
return 2 if result["summary"]["errors"] else 0 return 2 if result["summary"]["errors"] else 0
if args.command == "bootstrap":
instance = get_instance(inst_roots, args.instance)
root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
if not lock_path.is_absolute():
lock_path = (root / lock_path).resolve()
lockfile = load_lockfile(lock_path)
result = run_bootstrap(
instance=args.instance,
instance_path=instance.path,
beat_saber_version=lockfile.beat_saber_version,
registry=load_registry(registry_path),
lockfile=lockfile,
state_root=st_root,
repo_root=root,
proton=Path(args.proton).expanduser() if args.proton else None,
progress=lambda message: print(f" {message}", flush=True),
)
if args.json:
_json(result)
else:
delta = result["delta"]
print(f"Bootstrap state: {result['statePath']}")
print(f"Plan: {result['planPath']}")
print(f"IPA.exe -n exit: {result['ipaExitCode']}")
print(
"Bootstrap files: "
f"{len(delta['created'])} created, {len(delta['mutated'])} mutated, "
f"{len(delta['removed'])} removed"
)
print(f"Health: {'ok' if result['health']['ok'] else 'error'}")
for message in result["health"]["messages"]:
print(f" {message}")
return 0 if result["health"]["ok"] else 2
if args.command == "bootstrap-check":
instance = get_instance(inst_roots, args.instance)
result = check_bsipa_health(instance.path, st_root, args.instance)
if args.json:
_json(result)
else:
print(f"BSIPA bootstrap: {'ok' if result['ok'] else 'error'}")
print(f"State: {result['statePath']}")
print(f"Log: {result['logPath']}")
for message in result["messages"]:
print(f" {message}")
return 0 if result["ok"] else 2
if args.command == "plan": if args.command == "plan":
instance = get_instance(inst_root, args.instance) instance = get_instance(inst_roots, args.instance)
root = repo_root() root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry) registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml" lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
@@ -366,7 +546,7 @@ def run(argv: list[str] | None = None) -> int:
return 0 return 0
if args.command == "uninstall": if args.command == "uninstall":
instance = get_instance(inst_root, args.instance) instance = get_instance(inst_roots, args.instance)
result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force) result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force)
print(f"Removed: {len(result['removed'])}") print(f"Removed: {len(result['removed'])}")
if result["skipped"]: if result["skipped"]:
@@ -376,12 +556,22 @@ def run(argv: list[str] | None = None) -> int:
return 0 if result["stateUpdated"] else 2 return 0 if result["stateUpdated"] else 2
if args.command == "backup-userdata": if args.command == "backup-userdata":
instance = get_instance(inst_root, args.instance) instance = get_instance(inst_roots, args.instance)
result = backup_userdata(args.instance, instance.path, st_root) root = repo_root()
manifest = result["manifest"] backup_root = Path(args.backup_root).expanduser()
print(f"Archive: {result['archive']}") if not backup_root.is_absolute():
print(f"Files: {manifest['fileCount']}") backup_root = root / backup_root
print(f"Bytes: {manifest['totalSize']}") result = sync_windows_data_repo(
instance=args.instance,
instance_path=instance.path,
backup_root=backup_root,
appdata_path=Path(args.appdata_path).expanduser() if args.appdata_path else None,
include_appdata=not args.no_appdata,
)
print(f"Backup root: {result['backupRoot']}")
for item in result["copied"]:
print(f"{item['label']}: {item['fileCount']} files")
print(f" {item['source']} -> {item['destination']}")
return 0 return 0
except Exception as exc: except Exception as exc:
+118 -1
View File
@@ -1,16 +1,42 @@
from __future__ import annotations from __future__ import annotations
import json import json
import fnmatch
import shutil
import tarfile import tarfile
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from typing import Any from typing import Any, Callable
from .fsutil import sha256_file from .fsutil import sha256_file
from .state import backups_dir from .state import backups_dir
DEFAULT_BACKUP_EXCLUDES = (
"BeatLeader/Replays",
"BeatLeader/Replays/**",
"BeatLeader/ReplayerCache",
"BeatLeader/ReplayerCache/**",
"BeatLeader/LeaderboardsCache",
"BeatLeader/LeaderboardsCache/**",
"BeatLeader/ReplayHeadersCache",
"ScoreSaber/Replays",
"ScoreSaber/Replays/**",
"BeatSaberPlus/Cache",
"BeatSaberPlus/Cache/**",
"BeatSaverNotifier.json",
"Accsaber/PlayerScoreCache.json",
"NalulunaAvatars/cache",
"NalulunaAvatars/cache/**",
"SongDetailsCache.proto",
"com.unity.addressables",
"com.unity.addressables/**",
"*.log",
"*.log.*",
)
def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dict[str, Any]: def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dict[str, Any]:
source = instance_path / "UserData" source = instance_path / "UserData"
if not source.is_dir(): if not source.is_dir():
@@ -44,3 +70,94 @@ def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dic
handle.flush() handle.flush()
archive.add(handle.name, arcname="manifest.json") archive.add(handle.name, arcname="manifest.json")
return {"archive": str(destination), "manifest": manifest} return {"archive": str(destination), "manifest": manifest}
def infer_windows_appdata_path(instance_path: Path) -> Path:
parts = instance_path.resolve().parts
try:
users_index = parts.index("Users")
except ValueError as exc:
raise ValueError(f"cannot infer Windows user profile from instance path: {instance_path}") from exc
if users_index + 1 >= len(parts):
raise ValueError(f"cannot infer Windows user profile from instance path: {instance_path}")
profile = Path(*parts[: users_index + 2])
return profile / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber"
def sync_windows_data_repo(
*,
instance: str,
instance_path: Path,
backup_root: Path,
appdata_path: Path | None = None,
include_appdata: bool = True,
) -> dict[str, Any]:
sources: list[tuple[str, Path, Path]] = [
("UserData", instance_path / "UserData", backup_root / "UserData"),
]
if include_appdata:
appdata = appdata_path or infer_windows_appdata_path(instance_path)
sources.append(("AppData", appdata, backup_root / "AppData"))
for label, source, _ in sources:
if not source.is_dir():
raise FileNotFoundError(f"{label} directory not found: {source}")
backup_root.mkdir(parents=True, exist_ok=True)
copied: list[dict[str, Any]] = []
skipped: list[str] = []
for label, source, destination in sources:
if destination.exists():
shutil.rmtree(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(
source,
destination,
symlinks=True,
ignore=_ignore_backup_paths(source, skipped),
)
copied.append(
{
"label": label,
"source": str(source),
"destination": str(destination),
"fileCount": sum(1 for item in destination.rglob("*") if item.is_file()),
}
)
manifest = {
"createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"instance": instance,
"sources": copied,
"excludePatterns": list(DEFAULT_BACKUP_EXCLUDES),
"skipped": sorted(set(skipped)),
}
(backup_root / "backup-descriptor.json").write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
return {
"backupRoot": str(backup_root),
"copied": copied,
"manifest": manifest,
}
def _ignore_backup_paths(source_root: Path, skipped: list[str]) -> Callable[[str, list[str]], set[str]]:
def ignore(current_dir: str, names: list[str]) -> set[str]:
ignored: set[str] = set()
current_path = Path(current_dir)
for name in names:
relative = (current_path / name).relative_to(source_root).as_posix()
if _is_excluded(relative):
ignored.add(name)
skipped.append(relative)
return ignored
return ignore
def _is_excluded(relative_path: str) -> bool:
return any(fnmatch.fnmatchcase(relative_path, pattern) for pattern in DEFAULT_BACKUP_EXCLUDES)
+237 -4
View File
@@ -1,21 +1,26 @@
from __future__ import annotations from __future__ import annotations
import json
import tempfile import tempfile
import unittest import unittest
import os
from io import StringIO
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from zipfile import ZipFile from zipfile import ZipFile
from plugin_helper.bootstrap import _run_ipa
from plugin_helper.checker import check_lock from plugin_helper.checker import check_lock
from plugin_helper.cli import installed_plugins_report from plugin_helper.cli import installed_plugins_report, run
from plugin_helper.fsutil import sha256_file from plugin_helper.fsutil import sha256_file
from plugin_helper.installer import apply_plan, uninstall_plugin from plugin_helper.installer import apply_plan, uninstall_plugin
from plugin_helper.instances import get_instance, list_instances from plugin_helper.instances import get_instance, list_instances
from plugin_helper.models import Lockfile, LockedPlugin, Registry, RegistryPlugin from plugin_helper.models import Lockfile, LockedPlugin, Registry, RegistryPlugin
from plugin_helper.planner import create_plan from plugin_helper.planner import create_plan
from plugin_helper.scanner import scan_instance from plugin_helper.scanner import scan_bootstrap_files, scan_instance
from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir, save_bootstrap_state
from plugin_helper.updates import check_updates from plugin_helper.updates import check_updates
from plugin_helper.userdata import backup_userdata from plugin_helper.userdata import backup_userdata, infer_windows_appdata_path, sync_windows_data_repo
class PluginHelperTests(unittest.TestCase): class PluginHelperTests(unittest.TestCase):
@@ -39,6 +44,87 @@ class PluginHelperTests(unittest.TestCase):
self.assertEqual(scan["files"][0]["path"], "Plugins/Example.dll") self.assertEqual(scan["files"][0]["path"], "Plugins/Example.dll")
self.assertIn("sha256", scan["files"][0]) self.assertIn("sha256", scan["files"][0])
def test_multi_root_instances_and_ambiguous_lookup(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
windows = root / "windows"
local = root / "local"
win_inst = windows / "1.44.1"
local_inst = local / "1.44.1"
(win_inst / "Beat Saber_Data").mkdir(parents=True)
(local_inst / "Beat Saber_Data").mkdir(parents=True)
instances = list_instances([windows, local])
self.assertEqual(len(instances), 2)
self.assertEqual({item.path for item in instances}, {win_inst, local_inst})
with self.assertRaisesRegex(ValueError, "ambiguous"):
get_instance([windows, local], "1.44.1")
def test_menu_selects_instance_and_action(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
instance = root / "instances" / "1.40.8"
state = root / "state"
(instance / "Beat Saber_Data").mkdir(parents=True)
(instance / "Plugins").mkdir()
answers = iter(["1", "3", "q"])
output = StringIO()
with patch("builtins.input", side_effect=lambda _: next(answers)), patch("sys.stdout", output):
status = run(
[
"--instances-root",
str(root / "instances"),
"--state-dir",
str(state),
"menu",
]
)
self.assertEqual(status, 0)
self.assertIn("Counts files currently present", output.getvalue())
def test_menu_routes_duplicate_instance_names_by_selected_root(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
first_root = root / "a-root"
second_root = root / "z-root"
first = first_root / "1.44.1"
second = second_root / "1.44.1"
state = root / "state"
(first / "Beat Saber_Data").mkdir(parents=True)
(second / "Beat Saber_Data").mkdir(parents=True)
(second / "Plugins").mkdir()
(second / "Plugins" / "Example.dll").write_bytes(b"dll")
answers = iter(["2", "3", "q"])
output = StringIO()
with patch("builtins.input", side_effect=lambda _: next(answers)), patch("sys.stdout", output):
status = run(
[
"--instances-root",
os.pathsep.join([str(first_root), str(second_root)]),
"--state-dir",
str(state),
"menu",
]
)
self.assertEqual(status, 0)
self.assertIn("1.44.1: 1 files", output.getvalue())
def test_run_ipa_timeout_returns_control(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
result = _run_ipa(
command=["python", "-c", "import time; time.sleep(30)"],
instance_path=Path(tmp),
timeout_seconds=1,
)
self.assertTrue(result["timedOut"])
self.assertNotEqual(result["returncode"], 0)
def test_plan_apply_and_uninstall_dll(self) -> None: def test_plan_apply_and_uninstall_dll(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp) work = Path(tmp)
@@ -235,6 +321,103 @@ class PluginHelperTests(unittest.TestCase):
repo_root=work, repo_root=work,
) )
def test_scan_bootstrap_files_includes_root_ipa_and_bsipa_dirs(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
instance = Path(tmp)
(instance / "IPA" / "Backups").mkdir(parents=True)
(instance / "Libs").mkdir()
(instance / "winhttp.dll").write_bytes(b"proxy")
(instance / "IPA.exe").write_bytes(b"ipa")
(instance / "IPA.exe.config").write_bytes(b"config")
(instance / "IPA" / "Backups" / "Beat Saber.exe.bak").write_bytes(b"backup")
(instance / "Libs" / "0Harmony.dll").write_bytes(b"harmony")
paths = [item["path"] for item in scan_bootstrap_files(instance)]
self.assertEqual(
paths,
[
"IPA.exe",
"IPA.exe.config",
"IPA/Backups/Beat Saber.exe.bak",
"Libs/0Harmony.dll",
"winhttp.dll",
],
)
def test_plan_requires_healthy_bootstrap_for_locked_bsipa_dependencies(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.44.1"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
asset = plugin_downloads_dir(state, "1.44.1", "example") / "Example.dll"
asset.write_bytes(b"managed dll")
registry = Registry(
{
"bsipa": RegistryPlugin(
id="bsipa",
name="BSIPA",
repo=None,
install_strategy="root-zip",
),
"example": RegistryPlugin(
id="example",
name="Example",
repo=None,
install_strategy="dll-to-plugins",
),
}
)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(
LockedPlugin(id="bsipa", repo=None, tag="4.3.7", asset="BSIPA.zip", sha256=None),
LockedPlugin(
id="example",
repo=None,
tag="v1.0.0",
asset="Example.dll",
sha256=sha256_file(asset),
),
),
)
with self.assertRaisesRegex(ValueError, "BSIPA bootstrap is not healthy"):
create_plan(
instance="1.44.1",
instance_path=instance,
beat_saber_version="1.44.1",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
selected={"example"},
)
(instance / "IPA").mkdir()
(instance / "Libs").mkdir()
(instance / "Logs").mkdir()
(instance / "IPA.exe").write_bytes(b"ipa")
(instance / "winhttp.dll").write_bytes(b"proxy")
(instance / "Logs" / "_latest.log").write_text("Beat Saber IPA (BSIPA): 4.3.7\n", encoding="utf-8")
save_bootstrap_state(state, "1.44.1", {"files": scan_bootstrap_files(instance)})
plan, _ = create_plan(
instance="1.44.1",
instance_path=instance,
beat_saber_version="1.44.1",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
selected={"example"},
)
self.assertEqual(plan["changes"][0]["target"], "Plugins/Example.dll")
def test_userdata_backup_contains_manifest(self) -> None: def test_userdata_backup_contains_manifest(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) root = Path(tmp)
@@ -247,6 +430,56 @@ class PluginHelperTests(unittest.TestCase):
self.assertTrue(Path(result["archive"]).exists()) self.assertTrue(Path(result["archive"]).exists())
self.assertEqual(result["manifest"]["fileCount"], 1) self.assertEqual(result["manifest"]["fileCount"], 1)
def test_infer_windows_appdata_path_from_mounted_instance(self) -> None:
instance = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances/1.44.1")
self.assertEqual(
infer_windows_appdata_path(instance),
Path("/home/pleb/Windows/Users/pleb/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber"),
)
def test_sync_windows_data_repo_copies_into_stable_backup_root(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
instance = root / "Users" / "pleb" / "BSManager" / "BSInstances" / "1.44.1"
appdata = root / "Users" / "pleb" / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber"
backup_repo = root / "backup"
(instance / "UserData").mkdir(parents=True)
(instance / "UserData" / "settings.json").write_text("{}", encoding="utf-8")
(instance / "UserData" / "BeatLeader" / "Replays").mkdir(parents=True)
(instance / "UserData" / "BeatLeader" / "Replays" / "big.bsor").write_text("replay", encoding="utf-8")
(instance / "UserData" / "ScoreSaber" / "Replays").mkdir(parents=True)
(instance / "UserData" / "ScoreSaber" / "Replays" / "big.bsor").write_text("replay", encoding="utf-8")
(instance / "UserData" / "BeatSaberPlus" / "Cache").mkdir(parents=True)
(instance / "UserData" / "BeatSaberPlus" / "Cache" / "cached.dat").write_text("cache", encoding="utf-8")
(instance / "UserData" / "BeatSaverNotifier.json").write_text('{"refreshToken":"secret"}', encoding="utf-8")
(instance / "UserData" / "Accsaber").mkdir(parents=True)
(instance / "UserData" / "Accsaber" / "PlayerScoreCache.json").write_text("{}", encoding="utf-8")
appdata.mkdir(parents=True)
(appdata / "Player.log").write_text("log", encoding="utf-8")
(appdata / "settings.cfg").write_text("settings", encoding="utf-8")
result = sync_windows_data_repo(
instance="1.44.1",
instance_path=instance,
backup_root=backup_repo,
)
self.assertEqual(result["backupRoot"], str(backup_repo))
self.assertEqual((backup_repo / "UserData" / "settings.json").read_text(), "{}")
self.assertFalse((backup_repo / "UserData" / "BeatLeader" / "Replays").exists())
self.assertFalse((backup_repo / "UserData" / "ScoreSaber" / "Replays").exists())
self.assertFalse((backup_repo / "UserData" / "BeatSaberPlus" / "Cache").exists())
self.assertFalse((backup_repo / "UserData" / "BeatSaverNotifier.json").exists())
self.assertFalse((backup_repo / "UserData" / "Accsaber" / "PlayerScoreCache.json").exists())
self.assertFalse((backup_repo / "AppData" / "Player.log").exists())
self.assertEqual((backup_repo / "AppData" / "settings.cfg").read_text(), "settings")
descriptor = json.loads((backup_repo / "backup-descriptor.json").read_text(encoding="utf-8"))
self.assertEqual(descriptor["instance"], "1.44.1")
self.assertEqual(descriptor["sources"][0]["source"], str(instance / "UserData"))
self.assertIn("BeatLeader/Replays", descriptor["skipped"])
self.assertIn("*.log", descriptor["excludePatterns"])
def test_check_reports_missing_asset(self) -> None: def test_check_reports_missing_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp) work = Path(tmp)