Add known working config profile for reverting changes

This commit is contained in:
pleb
2026-07-05 17:06:51 -07:00
parent 545d407116
commit a94a1ed000
9 changed files with 386 additions and 7 deletions
+9
View File
@@ -133,6 +133,15 @@ currently enabled plugins (except `bsipa`) and `e` to enable all currently
unchecked plugins. With a single Beat Saber installation, the menu opens the unchecked plugins. With a single Beat Saber installation, the menu opens the
plugin table directly. Re-enabling a plugin auto-bootstraps BSIPA when needed. plugin table directly. Re-enabling a plugin auto-bootstraps BSIPA when needed.
Use `s` to save the set of currently enabled plugins as that instance's
known-good set, and `g` to restore it later: plugins outside the saved set are
disabled and any saved plugins that are currently disabled/missing are
re-enabled from local assets. This is handy after a newly added mod pulls in
extra dependencies you don't actually want — press `g` to snap back to
the combination you know works. Only one known-good set is kept per instance;
saving again overwrites it. The equivalent automation commands are
`save-known-good` and `restore-known-good`.
The individual subcommands are mostly for automation and debugging. If you use The individual subcommands are mostly for automation and debugging. If you use
them, pass `--state-dir` directly only when you intentionally want to override them, pass `--state-dir` directly only when you intentionally want to override
the configured state directory for one command. the configured state directory for one command.
+7
View File
@@ -88,6 +88,13 @@ For a batch install, check for:
- there are no repeated unhandled exceptions from newly installed plugins. - there are no repeated unhandled exceptions from newly installed plugins.
- the game reaches the main menu. - the game reaches the main menu.
Several `ERROR` and `WARNING` lines are benign on a healthy 1.44.1 stack (older
manifest warnings, BeatLeader optional-interop probes, brief
`AudioTimeSyncController` resume noise, custom-map `Leaderboard id ... not
found`, transient KeyRemapper controller reconnects, and similar). See
[`docs/notes/install-and-verify-plugins-1.44.1.md`](notes/install-and-verify-plugins-1.44.1.md#known-ipa-log-noise)
before treating every red log line as a plugin failure.
After the first successful launch, `plugin-helper bootstrap-check --instance After the first successful launch, `plugin-helper bootstrap-check --instance
<version>` should pass. Ordinary plugin plans for lockfiles that include BSIPA <version>` should pass. Ordinary plugin plans for lockfiles that include BSIPA
depend on that recorded bootstrap state and the latest IPA log. depend on that recorded bootstrap state and the latest IPA log.
@@ -83,6 +83,44 @@ Suggested log checks:
- custom songs still enumerate - custom songs still enumerate
- playlist and downloader UI still opens when relevant - playlist and downloader UI still opens when relevant
Treat the patterns in [Known IPA log noise](#known-ipa-log-noise) as non-blocking
unless they appear together with a startup failure, missing plugin load line, or
broken in-game behavior.
## Known IPA Log Noise
These messages show up in healthy 1.44.1 Windows VR sessions and do not, by
themselves, block continuing the install pass. They were confirmed on
`Logs/_latest.log` from 2026-07-05 after FailButton and the current gameplay
stack loaded successfully.
| Pattern | Example | Why it is usually harmless |
| --- | --- | --- |
| Older manifest game version | `[IPA/Loader] Mod <name> developed for game version 1.42.0, so it may not work properly.` | IPA warns when a plugin manifest predates 1.44.1. Common across the stack; only actionable if the plugin misbehaves in VR. |
| BeatLeader optional interop missing | `[BeatLeader] Plugin ScoreSaber not found, ScoreSaberInterop will not be loaded` | BeatLeader probes for optional companion mods. Expected when those mods are not installed. |
| ScoreSaber Harmony stub when ScoreSaber absent | `[Harmony] Could not find type named ScoreSaber.Core.ReplaySystem.HarmonyPatches.PatchHandleHMDUnmounted` | Harmless when ScoreSaber is not in the instance. |
| Vanilla custom-map leaderboard lookup | `[UnityEngine] Leaderboard id for custom_level_<hash><diff> not found` | Unity/official leaderboard plumbing on custom songs. BeatLeader API calls can still succeed in the same session. |
| Brief audio resume desync | `[UnityEngine] AudioTimeSyncController: audio should be playing ... isPlaying=False` | Short burst when resuming from pause or squat gate; not the same class of failure as HitsoundTweaks' `InvalidProgramException` on an affinity patch. |
| Transient XR controller drop | `[KeyRemapper/UnityXRInputManager] Left controller not found.` | Headset/controller reconnect noise. Controllers typically reappear a line or two later. |
| SiraUtil duplicate expose | `[SiraUtil] Could not expose NoteCutSoundEffectManager. It is already binded.` | Seen when multiple installers touch the same gameplay binding. |
| BSML duplicate type handlers | `[BeatSaberMarkupLanguage] Registering type handler BeatLeader.UI.BSML_Addons...` | BeatLeader registers BSML extensions that overlap core handlers. Cosmetic warning only so far. |
| BeatLeader SteamVR console | `[BeatLeader] SteamVR console connection failed: Unable to connect to the remote server` | Optional SteamVR console integration; unrelated to BeatLeader login/API traffic. |
| SongCore WIP cache folder | `[SongCore] Folder: '...CustomWIPLevels\Cache' is missing Info.dat file!` | Empty or placeholder WIP cache directory. |
| Settings migration noise | `[SettingsIO] Decode: Unknown property 'quality.depth_texture'` | Stale graphics setting key in saved settings. |
| DLC promo assets | `[DlcPromoPanelModel] No PromoPanel assets discovered` | Normal when promo panel content is absent. |
| SquatToBegin lifecycle notice | `[IPA/Loader] Plugin SquatToBegin has no methods marked [OnStart] or [OnEnable]` | Upstream plugin shape; gate still arms from gameplay scene setup. |
| IPA feature deprecation | `[IPA/Loader/Features] Encountered old features used` / `No such feature SiraLocalizer.LocalizedPlugin` | Loader metadata drift; not a load failure. |
Still treat these as real problems when investigating a failed smoketest:
- `Mono.Cecil.AssemblyResolutionException`, `TypeLoadException`, or missing
assembly errors for an installed plugin
- Harmony `Patching exception` or repeated `NullReferenceException` stacks from
a newly installed plugin (LeaderboardCore `PanelView_SetIsLoaded` is a known
pre-existing warning on this stack; revisit only if in-game leaderboards break)
- Plugin absent from the IPA load list after install
- Startup never reaches `[MainSystemInit] Initialization was completed`
## Status Legend ## Status Legend
- <span style="color:#8b949e; font-weight:600">todo</span>: not attempted - <span style="color:#8b949e; font-weight:600">todo</span>: not attempted
@@ -170,7 +208,7 @@ stable.
| Plugin | Upstream | Status | Source/version | Verification notes | | Plugin | Upstream | Status | Source/version | Verification notes |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| ScoreSaber | [github](https://github.com/ScoreSaber/pc-mod) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `ScoreSaber/pc-mod` tag `v3.3.27`, asset `ScoreSaber-v3.3.27-bs1.42.0-to-1.44.0-9b4cfcf.zip`; GitHub release digest matched downloaded asset | IPA loaded ScoreSaber 3.3.27 and synchronized its clock. Warning: FPFC/no-VR smoke logged `openxr_loader not found` while trying to get HMD info. | | ScoreSaber | [github](https://github.com/ScoreSaber/pc-mod) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `ScoreSaber/pc-mod` tag `v3.3.27`, asset `ScoreSaber-v3.3.27-bs1.42.0-to-1.44.0-9b4cfcf.zip`; GitHub release digest matched downloaded asset | IPA loads 3.3.27, but auth fails on 1.44.1: `/api/v2/game/auth` returns 403 `Official build does not match upload version hash` because upstream only registers upload-trust hashes through 1.44.0. |
| BeatLeader + LeaderboardCore | [github](https://github.com/BeatLeader/beatleader-mod) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `BeatLeader/beatleader-mod` tag `v0.10.0`, asset `BeatLeader-0.10.0-bs1.42+.zip`; GitHub release digest matched downloaded asset; archive bundles `Plugins/LeaderboardCore.dll`, superseding the standalone BeatMods CDN trial | IPA loaded BeatLeader 0.10.0 and bundled LeaderboardCore 1.7.0, made BeatLeader API requests, and patched 13 ScoreSaber installers. Warnings: optional interop plugins missing; FPFC/no-VR smoke logged OpenXR session not running; bundled LeaderboardCore logged a Harmony patch error for `PanelView_SetIsLoaded`. | | BeatLeader + LeaderboardCore | [github](https://github.com/BeatLeader/beatleader-mod) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `BeatLeader/beatleader-mod` tag `v0.10.0`, asset `BeatLeader-0.10.0-bs1.42+.zip`; GitHub release digest matched downloaded asset; archive bundles `Plugins/LeaderboardCore.dll`, superseding the standalone BeatMods CDN trial | IPA loaded BeatLeader 0.10.0 and bundled LeaderboardCore 1.7.0, made BeatLeader API requests, and patched 13 ScoreSaber installers. Warnings: optional interop plugins missing; FPFC/no-VR smoke logged OpenXR session not running; bundled LeaderboardCore logged a Harmony patch error for `PanelView_SetIsLoaded`. |
| AccSaber | [github](https://github.com/not-dexter/accsaber-reloaded-plugin) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | GitHub `not-dexter/accsaber-reloaded-plugin` tag `v1.1.3`, asset `1.40.8.zip`; GitHub release digest matched downloaded asset; latest release has no 1.44.x asset | IPA loaded AccSaber 1.1.3, but the smoke log hit `TypeLoadException: Invalid type AccSaber.Managers.AccSaberStore` for `AccSaberMissionScreen`. Removed from the live instance after the failed trial. | | AccSaber | [github](https://github.com/not-dexter/accsaber-reloaded-plugin) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | GitHub `not-dexter/accsaber-reloaded-plugin` tag `v1.1.3`, asset `1.40.8.zip`; GitHub release digest matched downloaded asset; latest release has no 1.44.x asset | IPA loaded AccSaber 1.1.3, but the smoke log hit `TypeLoadException: Invalid type AccSaber.Managers.AccSaberStore` for `AccSaberMissionScreen`. Removed from the live instance after the failed trial. |
| SongRankedBadge | [github](https://github.com/qe201020335/SongRankedBadge) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `qe201020335/SongRankedBadge` tag `v1.0.6`, asset `SongRankedBadge-1.0.6-bs1.40.0-88ee233.zip`; BeatMods version id 2267, zipHash `c6944b8a4b00b0c0bb1d44f273b3bb18` | IPA loaded SongRankedBadge 1.0.6, resolved SongDetailsCache, loaded song details, and initialized. Warning: manifest targets Beat Saber 1.40.0. | | SongRankedBadge | [github](https://github.com/qe201020335/SongRankedBadge) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `qe201020335/SongRankedBadge` tag `v1.0.6`, asset `SongRankedBadge-1.0.6-bs1.40.0-88ee233.zip`; BeatMods version id 2267, zipHash `c6944b8a4b00b0c0bb1d44f273b3bb18` | IPA loaded SongRankedBadge 1.0.6, resolved SongDetailsCache, loaded song details, and initialized. Warning: manifest targets Beat Saber 1.40.0. |
+1
View File
@@ -8,5 +8,6 @@ dependencies = [
{ id = "beatsabermarkuplanguage", constraint = ">=1.14.1" }, { id = "beatsabermarkuplanguage", constraint = ">=1.14.1" },
{ id = "sirautil", constraint = ">=3.3.1" }, { id = "sirautil", constraint = ">=3.3.1" },
{ id = "songcore", constraint = ">=3.16.0" }, { id = "songcore", constraint = ">=3.16.0" },
{ id = "protobuf-net", constraint = ">=3.0.102" },
{ id = "bsipa", constraint = ">=4.3.7" }, { id = "bsipa", constraint = ">=4.3.7" },
] ]
+51 -1
View File
@@ -14,7 +14,7 @@ from .github import fetch_releases
from .installer import apply_plan, disable_plugin, uninstall_plugin from .installer import apply_plan, disable_plugin, uninstall_plugin
from .instances import get_instance, list_instances from .instances import get_instance, list_instances
from .models import load_lockfile, load_registry from .models import load_lockfile, load_registry
from .operations import enable_disabled_plugin from .operations import enable_disabled_plugin, restore_known_good_set, save_known_good_set
from .planner import create_plan from .planner import create_plan
from .reports import installed_plugins_report, print_installed_plugins from .reports import installed_plugins_report, print_installed_plugins
from .scanner import scan_instance from .scanner import scan_instance
@@ -197,6 +197,24 @@ def build_parser() -> argparse.ArgumentParser:
enable.add_argument("--lockfile") enable.add_argument("--lockfile")
enable.add_argument("plugin") enable.add_argument("plugin")
save_known_good = subcommands.add_parser(
"save-known-good",
help="Record the currently enabled plugins as the known-good set for this instance",
parents=[_common_parent()],
)
save_known_good.add_argument("--instance", required=True)
save_known_good.add_argument("--json", action="store_true", help="Print full JSON output")
restore_known_good = subcommands.add_parser(
"restore-known-good",
help="Disable plugins outside the saved known-good set and re-enable any that are missing",
parents=[_common_parent()],
)
restore_known_good.add_argument("--instance", required=True)
restore_known_good.add_argument("--registry", default="registry/plugins")
restore_known_good.add_argument("--lockfile")
restore_known_good.add_argument("--json", action="store_true", help="Print full JSON output")
backup = subcommands.add_parser( backup = subcommands.add_parser(
"backup-userdata", "backup-userdata",
help="Copy UserData and Windows AppData into the adjacent backups repo", help="Copy UserData and Windows AppData into the adjacent backups repo",
@@ -520,6 +538,38 @@ def run(argv: list[str] | None = None) -> int:
print(f"State: {result['statePath']}") print(f"State: {result['statePath']}")
return 0 return 0
if args.command == "save-known-good":
result = save_known_good_set(instance=args.instance, state_root=st_root)
if args.json:
_json(result)
else:
print(f"Saved known-good set for {args.instance}: {len(result['pluginIds'])} enabled plugins.")
for plugin_id in result["pluginIds"]:
print(f" {plugin_id}")
return 0
if args.command == "restore-known-good":
instance = get_instance(inst_roots, args.instance)
progress = (lambda message: print(f" {message}", flush=True))
result = restore_known_good_set(
instance=args.instance,
instance_path=instance.path,
state_root=st_root,
registry=args.registry,
lockfile=args.lockfile,
progress=progress,
)
if args.json:
_json(result)
else:
print(f"Disabled: {len(result['disabled'])}")
print(f"Enabled: {len(result['enabled'])}")
for item in result["disableErrors"]:
print(f" disable error {item['plugin']}: {item['error']}")
for item in result["enableErrors"]:
print(f" enable error {item['plugin']}: {item['error']}")
return 2 if result["disableErrors"] or result["enableErrors"] else 0
if args.command == "backup-userdata": if args.command == "backup-userdata":
instance = get_instance(inst_roots, args.instance) instance = get_instance(inst_roots, args.instance)
root = repo_root() root = repo_root()
+71 -2
View File
@@ -6,10 +6,10 @@ from typing import Any
from .bootstrap import ensure_healthy_bootstrap from .bootstrap import ensure_healthy_bootstrap
from .config import repo_root from .config import repo_root
from .installer import apply_plan from .installer import apply_plan, disable_plugin
from .models import Lockfile, Registry, load_lockfile, load_registry from .models import Lockfile, Registry, load_lockfile, load_registry
from .planner import create_plan from .planner import create_plan
from .state import load_installed_state from .state import load_installed_state, load_known_good_state, save_known_good_state
def _resolve_paths( def _resolve_paths(
@@ -161,3 +161,72 @@ def enable_disabled_plugins(
enabled.append({"plugin": plugin_id, "planPath": str(batch_path), "applied": applied_by_plugin.get(plugin_id, 0)}) enabled.append({"plugin": plugin_id, "planPath": str(batch_path), "applied": applied_by_plugin.get(plugin_id, 0)})
return {"enabled": enabled, "errors": errors} return {"enabled": enabled, "errors": errors}
def save_known_good_set(*, instance: str, state_root: Path) -> dict[str, Any]:
"""Record the currently enabled plugin ids as the known-good set for this instance."""
installed_state = load_installed_state(state_root, instance)
plugin_ids = sorted(installed_state.get("plugins", {}))
known_good = {
"instance": instance,
"beatSaberVersion": installed_state.get("beatSaberVersion"),
"pluginIds": plugin_ids,
}
save_known_good_state(state_root, instance, known_good)
return known_good
def restore_known_good_set(
*,
instance: str,
instance_path: Path,
state_root: Path,
registry: str = "registry/plugins",
lockfile: str | None = None,
repo: Path | None = None,
progress: Callable[[str], None] | None = None,
) -> dict[str, Any]:
"""Disable plugins outside the saved known-good set and re-enable the ones that are missing."""
known_good = load_known_good_state(state_root, instance)
known_good_ids = set(known_good.get("pluginIds", []))
if not known_good:
raise KeyError(f"no known-good set is saved for this instance: {instance}")
installed_state = load_installed_state(state_root, instance)
currently_enabled = set(installed_state.get("plugins", {}))
to_disable = sorted(currently_enabled - known_good_ids)
disabled: list[str] = []
disable_errors: list[dict[str, str]] = []
for plugin_id in to_disable:
try:
result = disable_plugin(instance, instance_path, state_root, plugin_id, False)
if result["stateUpdated"]:
disabled.append(plugin_id)
else:
skipped = "; ".join(f"{item['path']} {item['reason']}" for item in result["skipped"])
disable_errors.append({"plugin": plugin_id, "error": skipped or "no files changed"})
except Exception as exc:
disable_errors.append({"plugin": plugin_id, "error": str(exc)})
installed_state = load_installed_state(state_root, instance)
currently_enabled = set(installed_state.get("plugins", {}))
to_enable = sorted(known_good_ids - currently_enabled)
enable_result = enable_disabled_plugins(
instance=instance,
instance_path=instance_path,
state_root=state_root,
plugin_ids=to_enable,
registry=registry,
lockfile=lockfile,
repo=repo,
progress=progress,
)
return {
"knownGood": known_good,
"disabled": disabled,
"disableErrors": disable_errors,
"enabled": [item["plugin"] for item in enable_result["enabled"]],
"enableErrors": enable_result["errors"],
}
+14
View File
@@ -64,3 +64,17 @@ def backups_dir(state_root: Path, instance: str) -> Path:
path = instance_state_dir(state_root, instance) / "backups" path = instance_state_dir(state_root, instance) / "backups"
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
return path return path
def known_good_state_path(state_root: Path, instance: str) -> Path:
return instance_state_dir(state_root, instance) / "known-good.json"
def load_known_good_state(state_root: Path, instance: str) -> dict[str, Any]:
return read_json(known_good_state_path(state_root, instance), {})
def save_known_good_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
state.setdefault("instance", instance)
state["savedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
atomic_write_json(known_good_state_path(state_root, instance), state)
+58 -3
View File
@@ -13,9 +13,14 @@ from textual.widgets import DataTable, Footer, Header, Static
from .bsipa import BSIPA_PLUGIN_ID from .bsipa import BSIPA_PLUGIN_ID
from .installer import disable_plugin from .installer import disable_plugin
from .models import load_lockfile, load_registry from .models import load_lockfile, load_registry
from .operations import enable_disabled_plugin, enable_disabled_plugins from .operations import (
enable_disabled_plugin,
enable_disabled_plugins,
restore_known_good_set,
save_known_good_set,
)
from .reports import installed_plugins_report from .reports import installed_plugins_report
from .state import load_installed_state from .state import load_installed_state, load_known_good_state
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -44,6 +49,8 @@ class PluginHelperTui(App[int]):
Binding("space", "toggle_plugin", "Toggle", priority=True), Binding("space", "toggle_plugin", "Toggle", priority=True),
Binding("d", "disable_all_plugins", "Disable all", priority=True), Binding("d", "disable_all_plugins", "Disable all", priority=True),
Binding("e", "enable_all_plugins", "Enable all", priority=True), Binding("e", "enable_all_plugins", "Enable all", priority=True),
Binding("s", "save_known_good", "Save known-good", priority=True),
Binding("g", "restore_known_good", "Restore known-good", priority=True),
Binding("r", "refresh", "Refresh"), Binding("r", "refresh", "Refresh"),
Binding("b", "back", "Back"), Binding("b", "back", "Back"),
Binding("q", "quit", "Quit"), Binding("q", "quit", "Quit"),
@@ -229,6 +236,53 @@ class PluginHelperTui(App[int]):
self._set_bulk_status("Enabled", changed, errors) self._set_bulk_status("Enabled", changed, errors)
self._show_plugins(preserve_status=True) self._show_plugins(preserve_status=True)
def action_save_known_good(self) -> None:
if self._busy or self.mode != "plugins" or self.selected_installation is None:
return
target = self.selected_installation
known_good = save_known_good_set(instance=target.instance_name, state_root=target.state_root)
count = len(known_good["pluginIds"])
self._set_status(f"Saved known-good set: {count} enabled plugins.")
async def action_restore_known_good(self) -> None:
if self._busy or self.mode != "plugins" or self.selected_installation is None:
return
target = self.selected_installation
if not load_known_good_state(target.state_root, target.instance_name):
self._set_status("No known-good set saved yet. Press s to save the current selection.")
return
self._busy = True
self._set_status("Restoring known-good set...")
try:
result = await asyncio.to_thread(
restore_known_good_set,
instance=target.instance_name,
instance_path=target.instance_path,
state_root=target.state_root,
repo=self.repo_root,
progress=self._operation_progress,
)
except Exception as exc:
self._set_status(f"Could not restore known-good set: {exc}")
return
finally:
self._busy = False
errors = [f"{item['plugin']}: {item['error']}" for item in result["disableErrors"]] + [
f"{item['plugin']}: {item['error']}" for item in result["enableErrors"]
]
if errors:
preview = "; ".join(errors[:3])
suffix = f"; {len(errors) - 3} more" if len(errors) > 3 else ""
self._set_status(
f"Restored known-good set: disabled {len(result['disabled'])}, "
f"enabled {len(result['enabled'])}; {len(errors)} failed: {preview}{suffix}"
)
else:
self._set_status(
f"Restored known-good set: disabled {len(result['disabled'])}, enabled {len(result['enabled'])}."
)
self._show_plugins(preserve_status=True)
def _operation_progress(self, message: str) -> None: def _operation_progress(self, message: str) -> None:
self.call_from_thread(self._set_status, message) self.call_from_thread(self._set_status, message)
@@ -294,7 +348,8 @@ class PluginHelperTui(App[int]):
if self.plugin_rows: if self.plugin_rows:
back_hint = "" if len(self.choices) == 1 else " b returns to installations." back_hint = "" if len(self.choices) == 1 else " b returns to installations."
self._set_status( self._set_status(
f"Space toggles selected. d disables all. e enables all.{back_hint}" "Space toggles selected. d disables all. e enables all. "
f"s saves known-good. g restores known-good.{back_hint}"
) )
else: else:
self._set_status("No version-locked plugins for this installation.") self._set_status("No version-locked plugins for this installation.")
+136
View File
@@ -861,6 +861,97 @@ sha256 = "{sha256_file(asset)}"
self.assertIn("example", updated["plugins"]) self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated["disabledPlugins"]) self.assertNotIn("example", updated["disabledPlugins"])
def test_save_and_restore_known_good_set(self) -> None:
from plugin_helper.operations import restore_known_good_set, save_known_good_set
from plugin_helper.state import load_known_good_state, save_installed_state
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance_root = work / "instances"
instance = instance_root / "1.40.8"
state = work / "state"
registry_dir = work / "registry"
locks_dir = work / "locks"
registry_dir.mkdir()
locks_dir.mkdir()
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
(instance / "Plugins").mkdir()
plugins_state: dict[str, dict] = {}
registry_entries: list[str] = []
lock_entries: list[str] = []
for plugin_id, name, filename in (
("alpha", "Alpha", "Alpha.dll"),
("beta", "Beta", "Beta.dll"),
("gamma", "Gamma", "Gamma.dll"),
):
asset = plugin_downloads_dir(state, "1.40.8", plugin_id) / filename
asset.write_bytes(f"{plugin_id} dll".encode())
registry_entries.append(
f"""
[[plugins]]
id = "{plugin_id}"
name = "{name}"
repo = "owner/{plugin_id}"
asset_patterns = ["*.dll"]
install_strategy = "dll-to-plugins"
""".lstrip()
)
lock_entries.append(
f"""
[[plugins]]
id = "{plugin_id}"
repo = "owner/{plugin_id}"
tag = "v1.0.0"
asset = "{filename}"
sha256 = "{sha256_file(asset)}"
""".lstrip()
)
if plugin_id in ("alpha", "beta"):
(instance / "Plugins" / filename).write_bytes(f"{plugin_id} dll".encode())
plugins_state[plugin_id] = {
"installedAt": "2026-06-14T17:18:40Z",
"files": [{"path": f"Plugins/{filename}", "sha256": sha256_file(asset), "size": asset.stat().st_size}],
}
(registry_dir / "plugins.toml").write_text("".join(registry_entries), encoding="utf-8")
(locks_dir / "1.40.8.lock.toml").write_text(
f"""
beat_saber_version = "1.40.8"
instance = "1.40.8"
{"".join(lock_entries)}
""".lstrip(),
encoding="utf-8",
)
save_installed_state(state, "1.40.8", {"instance": "1.40.8", "plugins": plugins_state, "disabledPlugins": {}})
known_good = save_known_good_set(instance="1.40.8", state_root=state)
self.assertEqual(known_good["pluginIds"], ["alpha", "beta"])
self.assertEqual(load_known_good_state(state, "1.40.8")["pluginIds"], ["alpha", "beta"])
# Simulate the user disabling alpha and a new mod pulling in gamma as a dependency.
disable_plugin("1.40.8", instance, state, "alpha", False)
with patch("plugin_helper.operations.repo_root", return_value=work):
enable_disabled_plugin(
instance="1.40.8",
instance_path=instance,
state_root=state,
plugin_id="gamma",
repo=work,
)
result = restore_known_good_set(instance="1.40.8", instance_path=instance, state_root=state, repo=work)
self.assertEqual(result["disabled"], ["gamma"])
self.assertEqual(result["enabled"], ["alpha"])
self.assertEqual(result["disableErrors"], [])
self.assertEqual(result["enableErrors"], [])
updated = load_installed_state(state, "1.40.8")
self.assertEqual(set(updated["plugins"]), {"alpha", "beta"})
self.assertIn("gamma", updated["disabledPlugins"])
def test_zip_to_pending_targets_ipa_pending(self) -> None: def test_zip_to_pending_targets_ipa_pending(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp) work = Path(tmp)
@@ -1892,6 +1983,51 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("example", updated["disabledPlugins"]) self.assertNotIn("example", updated["disabledPlugins"])
self.assertIn("Enabled 1 plugins", app.status_message) self.assertIn("Enabled 1 plugins", app.status_message)
async def test_save_known_good_records_currently_enabled_plugins(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, _instance, state = _make_two_plugin_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
await pilot.press("s")
await pilot.pause()
from plugin_helper.state import load_known_good_state
known_good = load_known_good_state(state, "1.40.8")
self.assertEqual(known_good["pluginIds"], ["alpha", "beta"])
self.assertIn("Saved known-good set: 2 enabled plugins", app.status_message)
async def test_restore_known_good_reverts_manual_changes(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_two_plugin_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
await pilot.press("s")
await pilot.pause()
# Manually disable beta after saving the known-good set.
await pilot.press("down")
await pilot.press("space")
await pilot.pause()
self.assertFalse((instance / "Plugins" / "Beta.dll").exists())
await pilot.press("g")
await pilot.pause()
self.assertIn("Restored known-good set", app.status_message)
self.assertTrue((instance / "Plugins" / "Beta.dll").exists())
updated = load_installed_state(state, "1.40.8")
self.assertEqual(set(updated["plugins"]), {"alpha", "beta"})
async def test_restore_known_good_without_saved_set_reports_status(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, _instance, _state = _make_two_plugin_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
await pilot.press("g")
await pilot.pause()
self.assertIn("No known-good set saved yet", app.status_message)
async def test_space_reports_hash_mismatch_without_state_update(self) -> None: async def test_space_reports_hash_mismatch_without_state_update(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp), hash_mismatch=True) app, instance, state = _make_tui_fixture(Path(tmp), hash_mismatch=True)