Compare commits

...

7 Commits

Author SHA1 Message Date
pleb 17bd736e59 Document plugin helper agent workflow 2026-06-28 14:36:14 -07:00
pleb f085bbb802 Record Beat Saber 1.44.1 plugin migration state 2026-06-28 14:36:14 -07:00
pleb 8ad2a3dd35 Add BSIPA bootstrap support 2026-06-28 14:36:14 -07:00
pleb 158bc23298 Store Beat Saber backups in adjacent repo 2026-06-28 14:36:14 -07:00
pleb 7639fb7270 Add Beat Saber data backup command 2026-06-28 14:27:12 -07:00
pleb 931c1d4f73 Add plugin update and install reporting 2026-06-28 12:12:57 -07:00
pleb 5a9e873de4 Add notes about project plan and current state of our beat saber installation 2026-06-28 12:11:33 -07:00
23 changed files with 2843 additions and 72 deletions
@@ -1,15 +1,19 @@
---
name: install-beatsaber-plugin
description: Install or update a Beat Saber plugin in the plugin-helper repo by using the local helper workflow. Use when the user asks to add, install, update, bump, lock, or manage a Beat Saber plugin release for a BSManager instance. The user must provide an explicit GitHub release URL in the prompt; if no release URL is present, ask for one and do not infer, search for, or substitute a different repository.
description: Install or update a Beat Saber plugin in the plugin-helper repo by using the local helper workflow. Use when the user asks to add, install, update, bump, lock, bootstrap BSIPA, or manage a Beat Saber plugin release for a BSManager instance. Prefer upstream GitHub release artifacts for normal plugins; use BeatMods primarily as compatibility/dependency metadata, with CDN artifacts only for inaccessible upstream assets, BeatMods-only packages, or framework/library dependencies.
---
# Install Beat Saber Plugin
Use the repository's own `plugin-helper` commands to manage plugins for BSManager instances. Do not manually copy release files into the game instance except to undo your own mistaken install before rerunning the helper.
Use the repository's own `plugin-helper` commands to manage plugins for BSManager instances whenever the helper supports the operation. Do not manually copy release files into the game instance except:
- to bootstrap BSIPA/core packages before the helper has a first-class bootstrap command
- to undo your own mistaken install before rerunning the helper
## Hard Guardrail
Require an explicit GitHub release URL from the user's prompt before selecting any release or repository.
For ordinary GitHub-hosted plugins, require an explicit GitHub release URL from
the user's prompt before selecting any release or repository.
- If the prompt does not contain a GitHub release URL, stop and ask the user for it.
- Do not search the web to discover a repository or "correct" release URL.
@@ -17,6 +21,17 @@ Require an explicit GitHub release URL from the user's prompt before selecting a
- If the provided URL is a general releases page, use that repo's release API and choose the latest non-draft, non-prerelease release unless the user asks for a specific tag/version.
- If the provided URL is a tag URL, use that exact tag.
Exception: if the user explicitly asks to bootstrap a Beat Saber version or
install verified mods without providing GitHub URLs, use BeatMods metadata to
identify compatible versions and dependency closure. Still prefer upstream
GitHub release artifacts when BeatMods exposes a `gitUrl` and a matching
release/asset can be found. Use BeatMods CDN artifacts only when the upstream
artifact is inaccessible, no matching upstream release asset exists, the package
is effectively BeatMods-only, or the package is a framework/library dependency
such as .NET assemblies. Record the artifact source plus BeatMods `modVersion`,
version id, `zipHash`, dependencies, and supported game version in the repo
notes/lock data.
Accepted URL shapes include:
```text
@@ -44,6 +59,7 @@ https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
sed -n '1,220p' src/plugin_helper/models.py
sed -n '1,220p' registry/plugins.toml
sed -n '1,220p' locks/<instance>.lock.toml
sed -n '1,220p' docs/SMOKETEST.md
```
3. Determine the instance.
@@ -54,21 +70,42 @@ https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
PYTHONPATH=src python -m plugin_helper instances
```
4. Snapshot first when requested.
4. Resolve the release source.
If the user asks for a one-time or pre-helper snapshot, archive the instance's `Plugins/` directory before any install:
For BeatMods bootstrap or verified packages, query BeatMods with a browser-like user agent:
```bash
mkdir -p "$HOME/archive/beatsaber"
tar -C "<instance-root>/<instance>" -czf "$HOME/archive/beatsaber/<instance>-Plugins-pre-helper-<timestamp>.tar.gz" Plugins
sha256sum "$HOME/archive/beatsaber/<instance>-Plugins-pre-helper-<timestamp>.tar.gz"
python - <<'PY'
import json, urllib.request
game_version = "<instance>"
url = f"https://beatmods.com/api/mods?status=verified&gameVersion={game_version}&gameName=BeatSaber&platform=steampc"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 plugin-helper"})
with urllib.request.urlopen(req, timeout=20) as response:
data = json.load(response)
mods = data["mods"] if isinstance(data, dict) and "mods" in data else data
print(json.dumps(mods, indent=2)[:20000])
PY
```
Report the archive path and hash.
BeatMods dependency entries are mod-version ids. Resolve the selected mod's
dependency closure before downloading. For each resolved package, prefer its
upstream `gitUrl` release artifacts when a matching release asset exists.
Fall back to BeatMods CDN only for inaccessible/missing upstream assets,
BeatMods-only packages, or framework/library dependencies. CDN URLs are:
5. Resolve the release from the user-provided URL only.
```text
https://beatmods.com/cdn/mod/<zipHash>.zip
```
For GitHub URLs, derive `<owner>/<repo>` and optional `<tag>` from the URL. Query the GitHub API directly for metadata:
For BSIPA bootstrap, expect the archive to contain root-relative `IPA/` and
`IPA.exe` files whether sourced from GitHub or BeatMods. Extract it into the
instance root and run `IPA.exe -n` under the same Proton environment used by
the smoketest. This creates/copies the root `winhttp.dll` and root `Libs/`
substrate that IPA needs.
For GitHub URLs, resolve the release from the user-provided URL only.
Derive `<owner>/<repo>` and optional `<tag>` from the URL. Query the GitHub API directly for metadata:
```bash
curl -sS https://api.github.com/repos/<owner>/<repo>/releases
@@ -77,31 +114,31 @@ https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
Pick the asset that matches the Beat Saber instance/version. Prefer an exact versioned asset such as `1.40.8.zip` over broad or source archives. If multiple plausible assets remain, ask the user.
6. Inspect the asset before selecting an install strategy.
5. Inspect the asset before selecting an install strategy.
Download to the helper state directory:
```bash
mkdir -p .state/instances/<instance>/downloads
curl -L --fail -o .state/instances/<instance>/downloads/<asset-name> "<browser_download_url>"
sha256sum .state/instances/<instance>/downloads/<asset-name>
mkdir -p .state/instances/<instance>/downloads/<plugin-id>
curl -L --fail -o .state/instances/<instance>/downloads/<plugin-id>/<asset-name> "<browser_download_url>"
sha256sum .state/instances/<instance>/downloads/<plugin-id>/<asset-name>
```
Match the checksum against GitHub's `digest` when available. Inspect zip contents:
```bash
unzip -l .state/instances/<instance>/downloads/<asset-name>
unzip -l .state/instances/<instance>/downloads/<plugin-id>/<asset-name>
```
Strategy guide:
- `dll-to-plugins`: asset is a single `.dll` that belongs in `Plugins/`.
- `bsipa-zip`: zip top-level paths are only `IPA/`, `Libs/`, or `Plugins/`.
- `root-zip`: zip contains valid game-root paths outside the BSIPA top-level set.
- `root-zip`: zip contains valid game-root paths outside the BSIPA top-level set. Use this for BSIPA/bootstrap archives because `IPA.exe`, `IPA.runtimeconfig*.json`, and root `winhttp.dll` are game-root files.
- `zip-to-pending`: only when the release is intended for `IPA/Pending/`.
- `manual`: do not use for installable releases.
7. Update the registry and lockfile.
6. Update the registry and lockfile.
Add or update exactly one `[[plugins]]` entry in `registry/plugins.toml` with:
@@ -128,7 +165,7 @@ https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
Preserve unrelated registry and lockfile content. Do not invent dependency versions unless they are explicitly stated by the provided release notes or existing local metadata.
8. Use the helper to validate, plan, and apply.
7. Use the helper to validate, plan, and apply.
Always pass `--state-dir .state` so the helper uses the repo-local downloaded asset:
@@ -140,7 +177,7 @@ https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
Before applying, read or summarize the generated plan enough to confirm it changes only the intended plugin files.
9. Verify the result.
8. Verify the result.
Confirm the installed file hashes match the plan or archive members:
@@ -152,12 +189,33 @@ https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
Use `PYTHONPATH=src`; plain `python -m unittest` may fail in this source-layout repo.
10. Final response.
For live Beat Saber validation, follow `docs/SMOKETEST.md`. Do not rely on
`timeout` to kill the full game process tree. Prefer the documented
foreground Proton launch with a background watchdog that sleeps for the smoke
window, then terminates Beat Saber by process name. Confirm `Logs/_latest.log`
has the expected IPA/plugin lines. If the game remains open after the
watchdog cleanup, say so and ask the user to close it manually rather than
leaving the turn with Beat Saber running.
For BSIPA/SongCore bootstrap, expected successful log lines include:
```text
Game version <version>
Loading plugins from Plugins and found <n>
Beat Saber IPA (BSIPA): <version>
SongCore (SongCore): <version>
```
Warnings about older mod target game-version metadata can be acceptable when
BeatMods verified that exact package for the target Beat Saber version, but
record them in the tracker or roadmap. Also record when a BeatMods CDN
artifact was used so it can be migrated to upstream GitHub later if possible.
9. Final response.
Include:
- release URL/tag/asset used
- snapshot path and hash, if created
- files changed in the repo and helper state
- live instance files changed
- backup path created by the helper
+49
View File
@@ -0,0 +1,49 @@
# AGENTS.md
Guidance for coding agents working in this repo.
## Project Shape
- This repo manages Beat Saber plugins for BSManager instances.
- Default instance roots are:
- `/home/pleb/Windows/Users/pleb/BSManager/BSInstances`
- `/home/pleb/.local/share/BSManager/BSInstances`
- A local BSManager source checkout may be available at
`/home/pleb/src/Zagrios/bs-manager`. Use it as a read-only reference when
investigating launch behavior, inherited Steam arguments, instance layout, or
Proton environment details unless the user explicitly asks for BSManager code
changes.
- Prefer repo-local state with `--state-dir .state` for planned installs unless
the task explicitly targets the user's live default state.
## Workflow Rules
- Run commands from the repo root with `PYTHONPATH=src`.
- For human-style inspection, prefer the menu with repo-local state:
`PYTHONPATH=src python -m plugin_helper --state-dir .state menu`.
- When targeting the local Linux BSManager install rather than the Windows
mirror, pass `--instances-root /home/pleb/.local/share/BSManager/BSInstances`
or choose that path explicitly in the menu.
- Use the helper commands instead of manually copying plugin files into an
instance.
- Treat BSIPA as a bootstrap phase:
- `bootstrap` installs the locked BSIPA archive and records generated files.
- ordinary plugin plans should depend on healthy bootstrap state.
- Be careful with duplicate instance names across Windows and local roots. Use
the menu or pass `--instances-root` explicitly when targeting one install.
## Validation
- Run `PYTHONPATH=src python -m unittest discover -s tests` after code changes.
- Run `PYTHONPATH=src python -m compileall -q src tests` for syntax/import
checks.
- For live game validation, follow `docs/SMOKETEST.md` and tear down Beat Saber
processes afterward.
## Launch Notes
- BSManager may inherit Beat Saber launch arguments configured in Steam.
- Do not assume a black screen is a plugin failure until checking
`Logs/_latest.log`, Unity `Player.log`, and the live process command line.
- Duplicate launch args such as `--no-yeet fpfc --no-yeet fpfc` can trigger a
fatal command-line parse error after BSIPA/plugin loading succeeds.
+82 -11
View File
@@ -1,6 +1,6 @@
# 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:
@@ -8,30 +8,101 @@ The first implementation focuses on safe local workflows:
- scan existing `Plugins/` and `Libs/` files
- read checked-in registry and per-version lockfiles
- generate a machine-readable install plan from local release assets
- apply exactly that plan with backups and install state
- apply exactly that plan and record install state
- uninstall only files recorded in install state
- back up `UserData` separately
Default BSManager instance root:
Default BSManager instance roots:
```text
/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 `:`.
## Quick Start
## Commands
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
python -m plugin_helper instances
python -m plugin_helper scan --instance 1.40.8
python -m plugin_helper plan --instance 1.40.8 --state-dir .state
PYTHONPATH=src python -m plugin_helper --state-dir .state menu
```
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:
```text
.state/instances/<instance>/downloads/
.state/instances/<instance>/downloads/<plugin-id>/
```
Future milestones will add GitHub release discovery and download.
## Beat Saber Data Backups
`backup-userdata` copies the mounted Windows `UserData` folder and Beat Saber
Windows app data into the adjacent `../backups` 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 backup 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
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).
+12
View File
@@ -76,6 +76,7 @@ Runtime state should not need to live inside the repository. By default, keep mu
installed.json
plans/
downloads/
<plugin-id>/
backups/
```
@@ -85,6 +86,17 @@ For early development, a `--state-dir` option is useful so plans and manifests c
The registry describes plugin sources and install behavior. It should be human-editable because many Beat Saber plugins have small packaging differences.
Artifact source preference:
1. Prefer upstream GitHub release artifacts for normal plugins.
2. Use BeatMods as compatibility, dependency, and verification metadata even
when the artifact comes from GitHub.
3. Use BeatMods CDN artifacts only when the upstream artifact is inaccessible,
the package is effectively BeatMods-only, or the package is a framework or
library dependency that does not have a normal plugin release source.
4. Record both the artifact source and any BeatMods `modVersion`/version-id/
`zipHash` metadata used to justify compatibility.
Example:
```toml
+23 -6
View File
@@ -8,6 +8,10 @@ The initial tool should stay conservative:
- Python owns instance discovery, dry-run plans, activation, install state, uninstall, and `UserData` backups.
- Release assets are selected through registry and lockfile data.
- Prefer upstream GitHub release artifacts for normal plugins. Use BeatMods as
compatibility/dependency metadata, and as an artifact source only for
inaccessible upstream artifacts, BeatMods-only packages, or framework/library
dependencies.
- Mutating operations apply an explicit plan and record exact file hashes.
- Nix packages `plugin-helper`, but does not directly manage the mutable Beat Saber tree.
@@ -64,12 +68,25 @@ All modes should still produce the same kind of explicit plan before applying.
## Proposed Milestones
1. Keep the Python safety harness stable: scan, plan, apply, uninstall, and backups.
2. Model one real plugin end to end with the current TOML lockfile and local asset planning.
3. Add a Nix function that fetches and unpacks one locked plugin asset into a normalized tree.
4. Generate a full plugin-set derivation for one Beat Saber version.
5. Teach `plugin-helper plan` to compare a Nix output tree against an instance.
6. Add `--activation-mode copy|symlink|materialize`.
7. Move compatibility and dependency metadata toward shared data that both Python and Nix can consume.
2. Model BSIPA bootstrap as a first-class install phase, preferring upstream GitHub release artifacts while preserving BeatMods `zipHash`/version metadata when used for verification or fallback.
3. Resolve BeatMods dependency closures by mod-version id for verified mods before ordinary batch planning, but keep artifact sourcing GitHub-preferred.
4. Model one real plugin end to end with the current TOML lockfile and local asset planning.
5. Add a Nix function that fetches and unpacks one locked plugin asset into a normalized tree.
6. Generate a full plugin-set derivation for one Beat Saber version.
7. Teach `plugin-helper plan` to compare a Nix output tree against an instance.
8. Add `--activation-mode copy|symlink|materialize`.
9. Move compatibility and dependency metadata toward shared data that both Python and Nix can consume.
## Warning Follow-Ups From 1.44.1 Bootstrap
The first 1.44.1 BSIPA/SongCore smoketest worked, but it produced warnings worth tracking separately from install success:
- BSML, SiraUtil, and SongCore have older target game-version metadata even though BeatMods verifies the selected releases for 1.44.1. Decide whether plugin-helper should treat BeatMods verification as a compatibility override.
- The first bootstrap used BeatMods CDN artifacts for speed. BSIPA, BSML, and SiraUtil have now been matched to byte-identical upstream GitHub release assets. SongCore remains a BeatMods CDN fallback because the BeatMods preferred repo `Kylemc1413/SongCore` currently exposes no matching 3.16.0 GitHub release asset.
- BSML reports missing Windows fonts under Proton. This is likely cosmetic, but may affect Unicode text rendering in mod UI.
- SongCore warns that `Beat Saber_Data/CustomWIPLevels/Cache` has no `Info.dat`. Either create the expected cache directory shape or classify this warning as harmless.
- SongCore could not read the audio rate for the built-in `Magic.wav` custom level and approximated duration from map length. Check whether this is a bundled-song oddity or a broader audio metadata issue.
- The smoketest launcher can leave Beat Saber running after timeout. Prefer explicit teardown and consider a helper command that starts, watches logs, and kills the process tree deterministically.
## Open Questions
+144
View File
@@ -0,0 +1,144 @@
# Beat Saber Smoketest Workflow
Use this workflow after installing or removing a plugin batch. It is adapted
from the Setlist repo's working Proton/BSManager smoketest notes.
The routine smoketest should be short: about 10 seconds wall time for launch and
log check, followed by immediate teardown. If expected plugin log lines do not
appear inside that window, treat that as a failure to investigate instead of
stretching the timeout.
## Preconditions
- Run from the target BSManager instance directory, for example:
```sh
cd "$HOME/.local/share/BSManager/BSInstances/1.44.1"
```
- The instance should contain BSIPA (`IPA/`, `IPA.exe`, `winhttp.dll`, and
`Plugins/`).
- If those files are absent, the run can still produce Unity `Player.log`
output, but it will not produce `Logs/_latest.log` or any IPA plugin-loading
evidence. Run `plugin-helper bootstrap --instance <version>` before using
this workflow to validate plugins.
- On Plasma + Wayland, the shell needs working desktop session variables:
`DISPLAY`, `WAYLAND_DISPLAY`, and `XDG_RUNTIME_DIR`. If the IDE terminal is
missing them, copy values from the logged-in desktop session.
## Launch
Run the Proton launch in the foreground with a short watchdog. Backgrounding
the Proton launch itself from an IDE terminal has been observed to exit early
before Beat Saber writes useful `_latest.log` lines.
Important: `timeout` may stop the launcher wrapper without killing the full
Beat Saber/Proton process tree. Prefer a watchdog that sleeps for the smoke
window and then kills the game process by name.
```sh
export SteamAppId=620980 SteamOverlayGameId=620980 SteamGameId=620980
export WINEDLLOVERRIDES='winhttp=n,b'
export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/BSManager/SharedContent/compatdata"
export STEAM_COMPAT_INSTALL_PATH="$PWD"
export STEAM_COMPAT_CLIENT_INSTALL_PATH="$HOME/.local/share/Steam"
export STEAM_COMPAT_APP_ID=620980
export SteamEnv=1
export OXR_PARALLEL_VIEWS=1
(
sleep 10
pkill -TERM -f "[B]eat Saber.exe" || pkill -TERM -f "[B]eat Saber" || true
sleep 2
pkill -KILL -f "[B]eat Saber.exe" || pkill -KILL -f "[B]eat Saber" || true
) &
smoke_watchdog_pid=$!
trap 'kill "$smoke_watchdog_pid" 2>/dev/null || true' EXIT
steam-run "$HOME/.local/share/Steam/steamapps/common/Proton - Experimental/proton" \
run "$PWD/Beat Saber.exe" --no-yeet fpfc 2>&1 | tee /tmp/bs-smoke.log
wait "$smoke_watchdog_pid" 2>/dev/null || true
trap - EXIT
```
Useful launch arguments:
| Argument | Use |
| --- | --- |
| `--verbose` | Opens the BSIPA console window. |
| `--debug` | Promotes debug logs to console output. |
| `--trace` | Enables very noisy BSIPA/internal traces. |
| `fpfc` | First-Person Flying Controller, useful for non-VR menu testing. |
| `--auto_play` | Built-in autoplayer for gameplay checks. |
## Log Checks
In another terminal, or immediately after the launch returns:
```sh
tail -F Logs/_latest.log
```
For a batch install, check for:
- BSIPA startup reaches plugin loading.
- the expected plugin names and versions appear in `Logs/_latest.log`.
- there are no missing assembly or dependency-resolution failures.
- there are no repeated unhandled exceptions from newly installed plugins.
- the game reaches the main menu.
After the first successful launch, `plugin-helper bootstrap-check --instance
<version>` should pass. Ordinary plugin plans for lockfiles that include BSIPA
depend on that recorded bootstrap state and the latest IPA log.
For Setlist specifically:
```sh
grep Setlist Logs/_latest.log
```
Expected Setlist signals include `platformUserId=...`, playlist lines with
`hasSyncUrl=...`, and `beatLeaderOwnerConfirmed=...`. When testing real playlist
sync, adding a map to an owned BeatLeader-synced playlist should log an HTTP
success line.
## Teardown
When the expected log lines are present before the watchdog fires, stop Beat
Saber immediately from a second terminal instead of waiting for the smoke window
to expire.
```sh
pkill -TERM -f "[B]eat Saber.exe" || pkill -TERM -f "[B]eat Saber" || true
sleep 2
pkill -KILL -f "[B]eat Saber.exe" || pkill -KILL -f "[B]eat Saber" || true
```
The bracketed pattern avoids matching the shell command that is running the
cleanup. If Beat Saber still remains open, close the game window manually and
then check for leftovers:
```sh
ps -eo pid,ppid,stat,comm,args | rg -i '[B]eat Saber|[P]roton.*Beat Saber|[w]ineserver|[s]team-run'
```
If the smoketest fails, kill leftover Beat Saber, Wine, or Proton processes
before retrying, then inspect `/tmp/bs-smoke.log`, Unity `Player.log` in the
Proton compatdata, and the timestamped files under `Logs/`.
## Plugin Development Notes From Setlist
These are relevant when `plugin-helper` installs locally built personal plugins.
- PC BSIPA plugins are .NET Framework class libraries loaded by BSIPA. Linux
`dotnet build` output is CIL and can be loaded by the Proton game instance.
- BSMT-style builds may copy the DLL directly into `<BeatSaberDir>/Plugins/`;
use `-p:DisableCopyToPlugins=True` when a build should not mutate the game
tree.
- `BeatSaberDir` in project user files or hint paths should point at the exact
BSManager instance being targeted.
- If a plugin build produces a shipped artifact, bump the plugin version before
the successful build so the IPA log line identifies the artifact under test.
- Setlist depends on BeatLeader being installed and signed in, PlaylistManager,
and BeatSaberPlaylistsLib. Its normal install target is `Plugins/Setlist.dll`.
@@ -0,0 +1,302 @@
# Beat Saber 1.44.1 Plugin Install and Verification
Tracking document for installing plugins into the Beat Saber 1.44.1 instance with
`plugin-helper`, then verifying the game loads and the IPA log stays clean enough
to continue.
Goal for this pass: get a working 1.44.1 plugin set. If a plugin blocks startup,
breaks song loading, or produces serious IPA errors, omit it and record the
failure. Do not fix incompatible plugins today.
## Source Baseline
Use `docs/notes/mods-used-in-1.40.8.md` as the starting inventory.
- 1.40.8 was installed through bs-manager plus some manual/private plugin drops.
- 1.44.1 was installed through bs-manager for the game only.
- 1.44.1 plugins should be installed through `plugin-helper`, not bs-manager.
- BeatMods and GitHub release metadata may differ from the 1.40.8 set, so every
selected plugin needs an explicit source/version recorded before install.
- Prefer upstream GitHub releases as the artifact source for normal plugins.
Use BeatMods as compatibility/dependency metadata by default, and as an
artifact source only when the upstream artifact is inaccessible, the package is
effectively BeatMods-only, or the package is a framework/library dependency
such as the .NET assemblies.
## Current 1.44.1 Instance Observation
As of 2026-06-28, the BSManager-managed 1.44.1 instance at
`~/.local/share/BSManager/BSInstances/1.44.1` appears to be an unpatched vanilla
game tree:
- root `IPA/`, `IPA.exe`, `winhttp.dll`, `Libs/`, `Logs/`, and `UserData` are
absent
- root `Plugins/` exists only because it was created manually and is empty
- the only pre-existing `Plugins` directory was `Beat Saber_Data/Plugins`, which
contains Unity/runtime native DLLs and must not be treated as the BSIPA mod
folder
By contrast, the Steam install for the same game version `1.44.1_20239` has
BSIPA instrumentation and support files at the game root:
- `IPA/`, `IPA.exe`, `winhttp.dll`
- `Libs/`
- populated root `Plugins/`
- `Logs/_latest.log`
- `UserData/`
This means BSManager's mod installation step does more than place user-selected
plugin DLLs. It also materializes the BSIPA loader and shared library substrate
that make IPA logs and BSIPA plugin loading possible. `plugin-helper` needs to
model that bootstrap layer separately from ordinary plugin batches.
The vanilla BSManager 1.44.1 launch did not produce an IPA log because BSIPA was
not present. It did produce a Unity `Player.log` under BSManager's shared Proton
compatdata and reached Steam/game initialization. So the missing bootstrap layer
is not proven to be required for vanilla Beat Saber to execute, but it is
required for the modded workflow and may affect the BSManager launch behavior we
are trying to reproduce.
## Verification Loop
Use the canonical smoketest workflow in `docs/SMOKETEST.md`.
For each batch:
1. Back up or snapshot the current 1.44.1 plugin state.
2. Install the selected batch with `plugin-helper`.
3. Start Beat Saber 1.44.1 with the foreground `timeout 10` Proton/FPFC launch.
4. Watch the IPA log during launch and first menu load.
5. Load a known-good custom song if the batch affects songs, playlists, maps, or
leaderboards.
6. Record result in this document.
7. If the game fails to load or logs serious plugin errors, remove the failing
plugin or batch and retry.
Suggested log checks:
- startup reaches main menu
- no plugin dependency resolution failures
- no repeated unhandled exceptions
- no missing assembly errors
- no hard failures from BSIPA, SiraUtil, BSML, SongCore, or CustomJSONData
- custom songs still enumerate
- playlist and downloader UI still opens when relevant
## Status Legend
- <span style="color:#8b949e; font-weight:600">todo</span>: not attempted
- <span style="color:#58a6ff; font-weight:600">planned</span>: source selected, ready to install
- <span style="color:#bc8cff; font-weight:600">installed</span>: copied into the instance
- <span style="color:#3fb950; font-weight:600">verified</span>: game launched and basic behavior checked
- <span style="color:#d29922; font-weight:600">verified with warning</span>: game launched, but the log had a non-blocking warning to track
- <span style="color:#f85149; font-weight:600">omitted</span>: skipped for this 1.44.1 pass due to failure or missing compatible release
- <span style="color:#dbab79; font-weight:600">defer</span>: intentionally left for a later pass
## plugin-helper Work Needed
Track the tool work discovered while using it for 1.44.1.
| Item | Status | Notes |
| --- | --- | --- |
| Add or generate a 1.44.1 lockfile | <span style="color:#8b949e; font-weight:600">todo</span> | Need exact versions, sources, hashes, and target paths. |
| Model BSIPA/bootstrap installation separately | <span style="color:#8b949e; font-weight:600">todo</span> | BSManager creates root `IPA/`, `IPA.exe`, `winhttp.dll`, `Libs/`, `Logs`, `UserData`, and root `Plugins/`. |
| Resolve BeatMods dependency closure | <span style="color:#8b949e; font-weight:600">todo</span> | Use as metadata/advisory input even when downloading plugin artifacts from upstream GitHub. |
| Install BeatMods library payloads into `Libs/` | <span style="color:#8b949e; font-weight:600">todo</span> | Include framework-library cases when required; these are likely exceptions to GitHub-preferred sourcing. |
| Support local/private plugin payloads | <span style="color:#8b949e; font-weight:600">todo</span> | Needed for paid closed-source and manual plugins. |
| Record install state for every copied file | <span style="color:#8b949e; font-weight:600">todo</span> | Required for rollback and clean omission testing. |
| Add a batch install workflow or documented command sequence | <span style="color:#8b949e; font-weight:600">todo</span> | Useful for two-or-three-at-a-time validation. |
| Add IPA log inspection helper | <span style="color:#8b949e; font-weight:600">todo</span> | Nice-to-have; manual log watching via `docs/SMOKETEST.md` is acceptable today. |
## Batch Plan
Install in small batches. Dependencies may be installed earlier than the plugin
that made them necessary, but record that relationship when it is known.
### Batch 0: Game and Loader Baseline
Purpose: verify the clean 1.44.1 game and loader before adding gameplay mods.
| Plugin | Status | Source/version | Verification notes |
| --- | --- | --- | --- |
| BSIPA | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `nike4613/BeatSaber-IPA-Reloaded` tag `4.3.7`, asset `BSIPA-net472-x64.zip`; BeatMods version id 2561, zipHash `947774ef1010ff809ae05e345e269a90` | GitHub asset is byte-identical to the BeatMods CDN zip used for initial bootstrap. Smoketest produced `Logs/_latest.log`; IPA reported game version 1.44.1 and BSIPA 4.3.7. |
### Batch 1: Core Song Loading
Purpose: get custom song loading working before UI, leaderboard, or cosmetic
mods are added.
| Plugin | Status | Source/version | Verification notes |
| --- | --- | --- | --- |
| SongCore | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 3.16.0, version id 2564, zipHash `0af9c0a03074c17ca15c1b667a0e30c8`; BeatMods preferred repo `Kylemc1413/SongCore` currently has no matching 3.16.0 GitHub release asset | IPA loaded SongCore; full song refresh loaded 2 songs from `CustomLevels`. Keep as BeatMods CDN fallback until a matching upstream asset is found. |
| BeatSaberMarkupLanguage | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `monkeymanboy/BeatSaberMarkupLanguage` tag `v1.14.1`, asset `BeatSaberMarkupLanguage-v1.14.1+bs.1.41.1-RELEASE.zip`; BeatMods version id 2567, zipHash `46149d03f8549e07f2c88fefde4337b2` | GitHub asset is byte-identical to the BeatMods CDN zip used for initial bootstrap. IPA loaded BSML; font fallback warnings only. |
| SiraUtil | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `Auros/SiraUtil` tag `v3.3.1`, asset `SiraUtil-v3.3.1+bs.1.42.0.zip`; BeatMods version id 2565, zipHash `ae14f7d3192a919d5d996c802fbde037` | GitHub asset is byte-identical to the BeatMods CDN zip used for initial bootstrap. IPA loaded SiraUtil and installed app/menu installers. |
### Batch 2: Custom Map Capabilities
Purpose: enable common map extensions after basic song loading is proven.
| Plugin | Status | Source/version | Verification notes |
| --- | --- | --- | --- |
| CustomJSONData | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `Aeroluna/CustomJSONData` tag `v2.6.8`, asset `CustomJSONData-2.6.8+1.40.0-bs1.40.0-7c2c32c.zip`; BeatMods version id 2327, zipHash `fed31638bbb678580ef760ec83cfd486` | GitHub asset is byte-identical to the BeatMods CDN zip. IPA loaded CustomJSONData 2.6.8+1.40.0; game reached main initialization; SongCore still loaded 2 custom songs. |
| Heck | <span style="color:#f85149; font-weight:600">omitted</span> | No BeatMods verified 1.44.1 entry found on 2026-06-28 | Required by Chroma/NoodleExtensions/Vivify; skip until a compatible source is identified. |
| Chroma | <span style="color:#f85149; font-weight:600">omitted</span> | No BeatMods verified 1.44.1 entry found on 2026-06-28 | Skip until Heck and a compatible Chroma source are identified. |
| NoodleExtensions | <span style="color:#f85149; font-weight:600">omitted</span> | No BeatMods verified 1.44.1 entry found on 2026-06-28 | Skip until Heck and a compatible NoodleExtensions source are identified. |
| Vivify | <span style="color:#f85149; font-weight:600">omitted</span> | No BeatMods verified 1.44.1 entry found on 2026-06-28 | Skip until Heck and a compatible Vivify source are identified. |
### Batch 3: Downloaders and Playlists
Purpose: restore in-game song discovery and playlist management.
| Plugin | Status | Source/version | Verification notes |
| --- | --- | --- | --- |
| BeatSaverDownloader | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 6.0.7, version id 2217, zipHash `a740c6e68a9b5d1dfda3cc8e81f7cf06`; BeatMods preferred repo `Top-Cat/BeatSaverDownloader` exposes no release assets through the GitHub releases API | IPA loaded BeatSaver Downloader 6.0.7 and started its internal webserver. Warning: it probed for missing `BetterSongList.dll` with IPA library-loader `CRITICAL` lines, then continued. |
| PlaylistManager | <span style="color:#f85149; font-weight:600">omitted</span> | No BeatMods verified 1.44.1 entry found on 2026-06-28 | Skip until a compatible source is identified. |
| BeatSaverUpdater | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `ibillingsley/BeatSaverUpdater` tag `1.2.11`, asset `BeatSaverUpdater-1.2.11-bs1.39.1-3698f98.zip`; BeatMods version id 2352, zipHash `d9ea8dd0cbaac66cbb02fa59a548e42b` | GitHub asset is byte-identical to the BeatMods CDN zip. IPA loaded BeatSaverUpdater 1.2.11. |
| BeatSaverVoting | <span style="color:#f85149; font-weight:600">omitted</span> | No BeatMods verified 1.44.1 entry found on 2026-06-28 | Skip until a compatible source is identified. |
| BeatSaberPlaylistsLib | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 1.7.2, version id 2175, zipHash `a3418b75ed7294a3856f3eca12bbd672`; BeatMods preferred repo `Meivyn/BeatSaberPlaylistsLib` exposes no release assets through the GitHub releases API | IPA loaded BeatSaberPlaylistsLib 1.7.2. |
| BeatSaverSharp | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 3.4.5, version id 1831, zipHash `be37e13e93d9ac7da4efbdc3f514fa8f`; BeatMods preferred repo `lolPants/BeatSaverSharp` was inaccessible through the GitHub releases API | IPA loaded BeatSaverSharp 3.4.5. |
| ScoreSaberSharp | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 0.1.0, version id 445, zipHash `8713168c598577ee7c73fa3cf0e26f5c`; BeatMods lists `scoresaber.com` rather than a GitHub release source | IPA loaded ScoreSaberSharp 0.1.0. Warning: bare manifest does not declare files. |
| BS Utils | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 1.14.3, version id 2563, zipHash `918d13ac2821a3a17b2819f8861453e9`; BeatMods preferred repo `Kylemc1413/Beat-Saber-Utils` exposes no matching 1.14.3 GitHub release asset | IPA loaded BS Utils 1.14.3. |
| Ini Parser | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 2.5.9, version id 1352, zipHash `5df74ad1c6b120fecdc615dd55f15b88` | IPA loaded INI Parser 2.5.9. |
| ImageSharp | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 2.0.0, version id 1428, zipHash `b642fec88b0f84a0643ebd401d08da35` | IPA loaded ImageSharp 2.0.0. |
| System.IO.Compression | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 4.6.57, version id 1763, zipHash `a4e9e26f61967e56168e08eecb01ab88` | IPA loaded System.IO.Compression 4.6.57. Warning: duplicate-library notice because the game also ships this assembly. |
| System.IO.Compression.FileSystem | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 4.7.3056, version id 1762, zipHash `e19f6fd395d54de7bfcbbbe3084dea28` | IPA loaded System.IO.Compression.FileSystem 4.7.3056. |
### Batch 4: Leaderboards and Ranking
Purpose: add online leaderboard/ranking integrations after core song behavior is
stable.
| Plugin | Status | Source/version | Verification notes |
| --- | --- | --- | --- |
| ScoreSaber | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify menu panel and song leaderboard. |
| BeatLeader | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify menu panel and song leaderboard. |
| LeaderboardCore | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Dependency for BeatLeader. |
| AccSaber | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual/plugin-helper registry candidate. |
| SongRankedBadge | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify song-list badges. |
### Batch 5: Practice and Gameplay Tweaks
Purpose: add small gameplay helpers two or three at a time.
| Plugin | Status | Source/version | Verification notes |
| --- | --- | --- | --- |
| IntroSkip | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify song start behavior. |
| FailButton | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify practice/fail UI behavior. |
| EasyOffset | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify settings UI opens. |
| GottaGoFast | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify startup and song load. |
| HitsoundTweaks | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify settings and audio behavior. |
| KeepMyOverridesPls | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify startup only unless override test is easy. |
| SoundReplacer | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify startup and settings. |
| KeyRemapper | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual install candidate. |
| SquatToBegin | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual install candidate. |
| JDFixer | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual install candidate. |
### Batch 6: UI and Song Browser
Purpose: restore song-list, menu, and visualization conveniences.
| Plugin | Status | Source/version | Verification notes |
| --- | --- | --- | --- |
| BetterSongList | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify song browser opens and filters work. |
| HitScoreVisualizer | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify settings UI and in-song display. |
| DiTails | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify details panel. |
| HideTheLogo | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify menu loads. |
| SongChartVisualizer | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify chart UI. |
| WhyIsThereNoLeaderboard | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Source TBD from 1.40.8 install. |
| Setlist | <span style="color:#8b949e; font-weight:600">todo</span> | local build or release | Requires BeatLeader signed in, PlaylistManager, and BeatSaberPlaylistsLib; verify `Setlist` log lines. |
| Custom Campaigns | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual install candidate. |
### Batch 7: Cosmetic, Camera, and Lighting
Purpose: add visual and stream-facing mods after functional mods are stable.
| Plugin | Status | Source/version | Verification notes |
| --- | --- | --- | --- |
| AdBlocker | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify startup. |
| HighlightBombs | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify in-song visuals. |
| PitchBlack | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify lighting behavior. |
| Dimmer | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual install candidate. |
| ReeCamera | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual install candidate. |
| ReeSabers | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual install candidate. |
### Batch 8: Paid or Closed-Source Plugins
Purpose: restore private plugin set only after public/dependency-heavy mods are
known good.
| Plugin | Status | Source/version | Verification notes |
| --- | --- | --- | --- |
| BeatSaberPlus_Chat | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup and module UI. |
| BeatSaberPlus_ChatEmoteRain | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| BeatSaberPlus_ChatIntegrations | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| BeatSaberPlus_ChatRequest | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| BeatSaberPlus_GameTweaker | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| BeatSaberPlus_MenuMusic | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| BeatSaberPlus_Multiplayer | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| BeatSaberPlus_NoteTweaker | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| BeatSaberPlus_SongChartVisualizer | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| BeatSaberPlus_SongOverlay | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| NalulunaMenu | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup and menu. |
| NalulunaCounters | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify in-song counters. |
| NalulunaLevelDetail | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify song detail panel. |
| NalulunaSliceVisualizer | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify in-song visuals. |
| NalulunaSongPreview | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify song preview. |
| NalulunaMissIndicator | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify in-song visuals. |
| NalulunaEnergy | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify in-song HUD. |
| NalulunaFps | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify overlay. |
| NalulunaPPCoin | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| NalulunaRewinder | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| NalulunaAvatars | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify avatar load. |
| NalulunaShaders | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify dependency for Naluluna visuals. |
| NalulunaSkybox | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify skybox sample manifest and skybox load. |
| NalulunaUtils | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify dependency load. |
## Shared Libraries and Dependency Packages
These should be installed because selected plugins require them, not because they
are user-facing features.
| Package | Status | Required by | Source/version | Verification notes |
| --- | --- | --- | --- | --- |
| AssetBundleLoadingTools | <span style="color:#8b949e; font-weight:600">todo</span> | Vivify | TBD | Usually `Libs/`. |
| BS Utils | <span style="color:#3fb950; font-weight:600">verified</span> | BeatSaverDownloader, BeatLeader | BeatMods 1.14.3, version id 2563, zipHash `918d13ac2821a3a17b2819f8861453e9` | IPA loaded BS Utils 1.14.3. |
| CameraUtils | <span style="color:#8b949e; font-weight:600">todo</span> | Vivify | TBD | Verify no missing assembly errors. |
| ImageSharp | <span style="color:#3fb950; font-weight:600">verified</span> | BeatSaberPlaylistsLib | BeatMods 2.0.0, version id 1428, zipHash `b642fec88b0f84a0643ebd401d08da35` | IPA loaded ImageSharp 2.0.0. |
| Ini Parser | <span style="color:#3fb950; font-weight:600">verified</span> | BS Utils | BeatMods 2.5.9, version id 1352, zipHash `5df74ad1c6b120fecdc615dd55f15b88` | IPA loaded INI Parser 2.5.9. |
| LookupID | <span style="color:#8b949e; font-weight:600">todo</span> | Chroma | TBD | Verify no missing assembly errors. |
| OpenVR API | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | TBD | Include only if a 1.44.1 dependency needs it. |
| protobuf-net | <span style="color:#8b949e; font-weight:600">todo</span> | SongDetailsCache | TBD | Usually `Libs/`. |
| SongDetailsCache | <span style="color:#8b949e; font-weight:600">todo</span> | BetterSongList, SongRankedBadge | TBD | Verify cache startup. |
| System.IO.Compression | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatSaberPlaylistsLib, System.IO.Compression.FileSystem | BeatMods 4.6.57, version id 1763, zipHash `a4e9e26f61967e56168e08eecb01ab88` | IPA loaded System.IO.Compression 4.6.57; logged a duplicate-library notice because the game also ships this assembly. |
| System.IO.Compression.FileSystem | <span style="color:#3fb950; font-weight:600">verified</span> | BeatSaverDownloader | BeatMods 4.7.3056, version id 1762, zipHash `e19f6fd395d54de7bfcbbbe3084dea28` | IPA loaded System.IO.Compression.FileSystem 4.7.3056. |
| Dynamic Bone | <span style="color:#dbab79; font-weight:600">defer</span> | TBD | TBD | Include only if a selected 1.44.1 mod requires it. |
| Final IK | <span style="color:#dbab79; font-weight:600">defer</span> | TBD | TBD | Include only if a selected 1.44.1 mod requires it. |
## Omitted Plugins
Record plugins skipped for this 1.44.1 pass. This is not a fix list for today.
| Plugin | Reason omitted | Evidence/log note | Follow-up |
| --- | --- | --- | --- |
| Heck | No BeatMods verified 1.44.1 entry found on 2026-06-28. | Not installed. | Revisit only with a compatible source. |
| Chroma | No BeatMods verified 1.44.1 entry found on 2026-06-28. | Not installed. | Revisit after Heck is available. |
| NoodleExtensions | No BeatMods verified 1.44.1 entry found on 2026-06-28. | Not installed. | Revisit after Heck is available. |
| Vivify | No BeatMods verified 1.44.1 entry found on 2026-06-28. | Not installed. | Revisit after Heck is available. |
| PlaylistManager | No BeatMods verified 1.44.1 entry found on 2026-06-28. | Not installed. | Revisit only with a compatible source. |
| BeatSaverVoting | No BeatMods verified 1.44.1 entry found on 2026-06-28. | Not installed. | Revisit only with a compatible source. |
## Batch Results
| Batch | Date | Result | IPA log notes | Action |
| --- | --- | --- | --- | --- |
| 0 | 2026-06-28 | <span style="color:#3fb950; font-weight:600">verified</span> | BSIPA 4.3.7 installed and generated `Logs/_latest.log`. | Continue to dependency/plugin batches. |
| 1 | 2026-06-28 | <span style="color:#3fb950; font-weight:600">verified</span> | BSML, SiraUtil, and SongCore loaded; SongCore loaded 2 custom songs. Warnings: older target game-version metadata, missing Windows fonts, missing `CustomWIPLevels/Cache/Info.dat`, and one built-in `Magic.wav` duration approximation. | Treat as acceptable bootstrap baseline. |
| 2 | 2026-06-28 | <span style="color:#3fb950; font-weight:600">verified</span> | CustomJSONData loaded; startup reached main initialization; SongCore still loaded 2 custom songs. Warnings: older target game-version metadata, missing Windows fonts, and missing `CustomWIPLevels/Cache/Info.dat`. | Continue to downloader/playlist batch. |
| 3 | 2026-06-28 | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatSaverDownloader, BeatSaverUpdater, BeatSaberPlaylistsLib, BeatSaverSharp, ScoreSaberSharp, BS Utils, Ini Parser, ImageSharp, and System.IO.Compression packages loaded; startup reached main initialization; SongCore still loaded 2 custom songs. Warning: BeatSaverDownloader probed for missing `BetterSongList.dll` with IPA library-loader `CRITICAL` lines, then continued and started its internal webserver. | Stop here per request; consider installing BetterSongList before deeper downloader UI testing. |
| 4 | | <span style="color:#8b949e; font-weight:600">todo</span> | | |
| 5 | | <span style="color:#8b949e; font-weight:600">todo</span> | | |
| 6 | | <span style="color:#8b949e; font-weight:600">todo</span> | | |
| 7 | | <span style="color:#8b949e; font-weight:600">todo</span> | | |
| 8 | | <span style="color:#8b949e; font-weight:600">todo</span> | | |
+184
View File
@@ -0,0 +1,184 @@
# 1.40.8 Mods in Use
Mods installed by bs-manager and not.
## bs-manager source of truth
bs-manager gets verified mods from BeatMods:
```text
https://beatmods.com/api/mods?status=verified&gameVersion=1.40.8&gameName=BeatSaber&platform=steampc
```
It downloads mod zips from `/cdn/mod/<zipHash>.zip`, resolves dependencies by BeatMods mod-version ids, and recognizes installed mods by MD5 hash lookup through `/api/hashlookup?hash=<md5>`.
For the mounted Windows install, `UserData/Disabled Mods.json` has an empty `DisabledModIds` array, so the files currently present in `Plugins/` and `Libs/` are enabled.
## Core
- BSIPA https://github.com/nike4613/BeatSaber-IPA-Reloaded
- SongCore https://github.com/Kylemc1413/SongCore
## Essential
- BeatSaverDownloader https://github.com/Top-Cat/BeatSaverDownloader
- BeatSaverVoting https://github.com/Top-Cat/BeatSaverVoting
- PlaylistManager https://github.com/rithik-b/PlaylistManager
- BeatSaverUpdater https://github.com/ibillingsley/BeatSaverUpdater
- SiraLocalizer https://github.com/Auros/SiraLocalizer
## Cosmetic
- AdBlocker https://github.com/JonnyVR1/AdBlocker
- HighlightBombs https://github.com/Meivyn/HighlightBombs
## Library
bs-manager installs these mostly as dependency closure. BeatMods records dependencies by mod-version id, so install planning needs to resolve the selected mod, then recursively add each dependency version before downloading zips from `zipHash`.
- AssetBundleLoadingTools https://github.com/nicoco007/AssetBundleLoadingTools
- Required by: Vivify.
- BeatSaberMarkupLanguage https://github.com/monkeymanboy/BeatSaberMarkupLanguage
- Required by: most UI/config mods here, including SongCore, SiraUtil, PlaylistManager, BeatSaverDownloader, Chroma, Vivify, ScoreSaber, and BeatLeader.
- BeatSaberPlaylistsLib https://github.com/Meivyn/BeatSaberPlaylistsLib
- Required by: PlaylistManager.
- BeatSaverSharp https://github.com/lolPants/BeatSaverSharp
- Required by: BeatSaverDownloader, BeatSaverUpdater, DiTails, PlaylistManager.
- BS Utils https://github.com/Kylemc1413/Beat-Saber-Utils
- Required by: BeatSaverDownloader, BeatSaverVoting, BeatLeader.
- CameraUtils https://github.com/Reezonate/CameraUtils
- Required by: Vivify.
- CustomJSONData https://github.com/Aeroluna/CustomJSONData
- Required by: Chroma, Heck, NoodleExtensions, Vivify.
- Dynamic Bone https://assetstore.unity.com/packages/tools/animation/dynamic-bone-16743
- BeatMods-managed library in this install, but no depender was found among the currently listed BeatMods-recognized mods.
- Final IK https://assetstore.unity.com/packages/tools/animation/final-ik-14290
- BeatMods-managed library in this install, but no depender was found among the currently listed BeatMods-recognized mods.
- Heck https://github.com/Aeroluna/Heck
- Required by: Chroma, NoodleExtensions, Vivify.
- ImageSharp https://github.com/SixLabors/ImageSharp/
- Required by: BeatSaberPlaylistsLib.
- Ini Parser https://github.com/rickyah/ini-parser
- Required by: BS Utils.
- LeaderboardCore https://github.com/NSGolova/LeaderboardCore
- Required by: BeatLeader.
- LookupID https://github.com/Aeroluna/Heck
- Required by: Chroma.
- OpenVR API https://github.com/nicoco007/BeatSaber-OpenVR-API
- Present in `Plugins/` as `OpenVRHelper.manifest`; no depender was found among the currently listed BeatMods-recognized mods.
- protobuf-net https://github.com/protobuf-net/protobuf-net
- Required by: SongDetailsCache.
- ScoreSaberSharp
- Required by: BeatSaverDownloader.
- SiraUtil https://github.com/Auros/SiraUtil
- Required by: most Sira/Auros-style mods here, including SongCore, PlaylistManager, SiraLocalizer, Chroma, NoodleExtensions, BeatLeader, ScoreSaber, and many UI/tweak mods.
- SongDetailsCache https://github.com/kinsi55/BeatSaber_SongDetails
- Required by: BetterSongList, SongRankedBadge.
When a library package is installed by bs-manager, the payload usually lands in `Libs/`; several packages also leave a `.manifest` marker in `Plugins/`. `plugin-helper` should treat both files as part of the dependency package's install state, not as separate user-selected plugins.
### .NET framework library assemblies
These are BeatMods `library` records that bs-manager installs into `Libs/`, not normal Beat Saber plugin repos with GitHub releases.
- System.IO.Compression https://github.com/mono/mono
- BeatMods library id 304.
- Provides stream compression/decompression classes.
- Installed file: `Libs/System.IO.Compression.dll`.
- System.IO.Compression.FileSystem https://github.com/microsoft/referencesource
- BeatMods library id 303.
- Provides the .NET Framework `ZipFile`/filesystem path helpers layered over `System.IO.Compression`.
- Installed file: `Libs/System.IO.Compression.FileSystem.dll`.
For `plugin-helper` to reproduce bs-manager behavior, these should be modeled as special framework-library dependencies instead of GitHub-release plugins:
- The dependency solver should be able to select BeatMods library ids 303 and 304 when another mod requires them, even though the BeatMods records do not expose normal version/download metadata in the 1.40.8 query.
- The installer should place the resolved DLLs in `Libs/`, never `Plugins/`.
- The install state should record them like any other installed file, including source, target path, size, and hash, so uninstall/rollback stays deterministic.
- The helper should not overwrite `Beat Saber_Data/Managed/System.IO.Compression*.dll`; those assemblies already exist in the game runtime and are a different size than the copies bs-manager put in `Libs/`.
- If a reusable source cannot be derived from BeatMods metadata, the registry needs an explicit rule or vendored/cache source for these two DLLs rather than a vague `dot.net` URL.
## Practice
- IntroSkip https://github.com/Loloppe/Intro-Skip
- FailButton https://github.com/qe201020335/FailButton
- NoodleExtensions https://github.com/Aeroluna/NoodleExtensions
- Vivify https://github.com/Aeroluna/Vivify
## UI
- HitScoreVisualizer https://github.com/ErisApps/HitScoreVisualizer
- WhyIsThereNoLeaderboard
- BetterSongList https://github.com/kinsi55/BeatSaber_BetterSongList
- DiTails https://github.com/Auros/DiTails/
- HideTheLogo https://github.com/TheBlackParrot/HideTheLogo
- SongChartVisualizer https://github.com/NuggoDEV/SongChartVisualizer
- SongRankedBadge https://github.com/qe201020335/SongRankedBadge
## Other
- BeatLeader https://github.com/BeatLeader/beatleader-mod
- ScoreSaber https://github.com/ScoreSaber/pc-mod
## Tweaks
- EasyOffset https://github.com/Reezonate/EasyOffset
- GottaGoFast https://github.com/kinsi55/CS_BeatSaber_GottaGoFast
- HitsoundTweaks https://github.com/GalaxyMaster2/HitsoundTweaks
- KeepMyOverridesPls https://github.com/qqrz997/KeepMyOverridesPls
- SoundReplacer https://github.com/Meivyn/SoundReplacer
## Lighting
- Chroma https://github.com/Aeroluna/Chroma
- PitchBlack https://github.com/Loloppe/BeatSaber_PitchBlack/
## Paid closed source
### BeatSaberPlus
- BeatSaberPlus_Chat (`BeatSaberPlus_Chat.dll`)
- BeatSaberPlus_ChatEmoteRain (`BeatSaberPlus_ChatEmoteRain.dll`)
- BeatSaberPlus_ChatIntegrations (`BeatSaberPlus_ChatIntegrations.dll`)
- BeatSaberPlus_ChatRequest (`BeatSaberPlus_ChatRequest.dll`)
- BeatSaberPlus_GameTweaker (`BeatSaberPlus_GameTweaker.dll`)
- BeatSaberPlus_MenuMusic (`BeatSaberPlus_MenuMusic.dll`)
- BeatSaberPlus_Multiplayer (`BeatSaberPlus_Multiplayer.dll`)
- BeatSaberPlus_NoteTweaker (`BeatSaberPlus_NoteTweaker.dll`)
- BeatSaberPlus_SongChartVisualizer (`BeatSaberPlus_SongChartVisualizer.dll`)
- BeatSaberPlus_SongOverlay (`BeatSaberPlus_SongOverlay.dll`)
### Naluluna
- NalulunaMenu (`NalulunaMenu.dll`)
- NalulunaCounters (`NalulunaCounters.dll`)
- NalulunaLevelDetail (`NalulunaLevelDetail.dll`)
- NalulunaSliceVisualizer (`NalulunaSliceVisualizer.dll`)
- NalulunaSongPreview (`NalulunaSongPreview.dll`)
- NalulunaMissIndicator (`NalulunaMissIndicator.dll`)
- NalulunaEnergy (`NalulunaEnergy.dll`)
- NalulunaFps (`NalulunaFps.dll`)
- NalulunaPPCoin (`NalulunaPPCoin.dll`)
- NalulunaRewinder (`NalulunaRewinder.dll`)
- NalulunaAvatars (`NalulunaAvatars.dll`)
- NalulunaShaders (`NalulunaShaders.dll`)
- NalulunaSkybox (`NalulunaSkybox.dll`, `NalulunaSkyboxSamples.manifest`)
- NalulunaUtils (`NalulunaUtils.dll`)
These mods were installed manually, not from bs-manager.
- ScoreSaber
- BeatLeader
- AccSaber
- ChatPlexSDK_BS
- Dimmer
- DiTails
- HideTheLogo
- HitsoundTweaks
- PitchBlack
- ReeCamera
- ReeSabers
- SoundReplacer
- BetterSongList
- Setlist
- SongChartVisualizer
- SongRankedBadge
- Chroma
- EasyOffset
- Custom Campaigns
- JDFixer
- KeepMyOverridesPls
- GottaGoFast
- KeyRemapper
- SquatToBegin
- wipbot
+134
View File
@@ -0,0 +1,134 @@
beat_saber_version = "1.44.1"
instance = "1.44.1"
[[plugins]]
id = "bsipa"
repo = "nike4613/BeatSaber-IPA-Reloaded"
tag = "4.3.7"
asset = "BSIPA-net472-x64.zip"
sha256 = "899b8a1dda91935bd5c19a211fa48e44e32f7be9ab82b6fc796709c753b7b2bc"
install_strategy = "root-zip"
reason = "BeatMods verified BSIPA 4.3.7 for Beat Saber 1.44.1 as version id 2561, zipHash 947774ef1010ff809ae05e345e269a90. GitHub asset is byte-identical to the BeatMods CDN zip used for initial bootstrap."
[[plugins]]
id = "beatsabermarkuplanguage"
repo = "monkeymanboy/BeatSaberMarkupLanguage"
tag = "v1.14.1"
asset = "BeatSaberMarkupLanguage-v1.14.1+bs.1.41.1-RELEASE.zip"
sha256 = "816613459853955e2b7c02123ed42f8e5bdbd946cc774fd5fcc4e80a6a09120a"
install_strategy = "bsipa-zip"
reason = "BeatMods verified BeatSaberMarkupLanguage 1.14.1 for Beat Saber 1.44.1 as version id 2567, zipHash 46149d03f8549e07f2c88fefde4337b2. GitHub asset is byte-identical to the BeatMods CDN zip used for initial bootstrap."
[[plugins]]
id = "sirautil"
repo = "Auros/SiraUtil"
tag = "v3.3.1"
asset = "SiraUtil-v3.3.1+bs.1.42.0.zip"
sha256 = "2253e5c31324e1e01b23480acba5ceee6b67eb8f16a079f58bc2f6ce7b12051f"
install_strategy = "bsipa-zip"
reason = "BeatMods verified SiraUtil 3.3.1 for Beat Saber 1.44.1 as version id 2565, zipHash ae14f7d3192a919d5d996c802fbde037. GitHub asset is byte-identical to the BeatMods CDN zip used for initial bootstrap."
[[plugins]]
id = "songcore"
repo = "Kylemc1413/SongCore"
tag = "beatmods-3.16.0"
asset = "SongCore-3.16.0.zip"
sha256 = "f9be5d66426d795c6c8af2a4da0150a298d2d6a65fbabcab316d379088a40019"
install_strategy = "bsipa-zip"
reason = "BeatMods verified SongCore 3.16.0 for Beat Saber 1.44.1 as version id 2564, zipHash 0af9c0a03074c17ca15c1b667a0e30c8. BeatMods points to Kylemc1413/SongCore, but that GitHub repo currently exposes releases only through 3.10.4, so this remains a BeatMods CDN fallback until a matching upstream release artifact is found."
[[plugins]]
id = "customjsondata"
repo = "Aeroluna/CustomJSONData"
tag = "v2.6.8"
asset = "CustomJSONData-2.6.8+1.40.0-bs1.40.0-7c2c32c.zip"
sha256 = "30555c77485d2837bd294608fe4e23c34415b89413997be67a6150318090b216"
install_strategy = "bsipa-zip"
reason = "BeatMods verified CustomJSONData 2.6.8+1.40.0 for Beat Saber 1.44.1 as version id 2327, zipHash fed31638bbb678580ef760ec83cfd486. GitHub asset is byte-identical to the BeatMods CDN zip."
[[plugins]]
id = "iniparser"
repo = "rickyah/ini-parser"
tag = "beatmods-2.5.9"
asset = "IniParser-2.5.9.zip"
sha256 = "b761293bea73ff3cb9998f404594aade617638aba4318203320189baf3449ff1"
install_strategy = "bsipa-zip"
reason = "BeatMods verified Ini Parser 2.5.9 for Beat Saber 1.44.1 as version id 1352, zipHash 5df74ad1c6b120fecdc615dd55f15b88. Use BeatMods CDN as a framework/library dependency payload."
[[plugins]]
id = "bs-utils"
repo = "Kylemc1413/Beat-Saber-Utils"
tag = "beatmods-1.14.3"
asset = "BSUtils-1.14.3.zip"
sha256 = "b450fa4561da5b5ad834a3dcf6f127c2d73e5f971aa6c986514a6b185d68edfe"
install_strategy = "bsipa-zip"
reason = "BeatMods verified BS Utils 1.14.3 for Beat Saber 1.44.1 as version id 2563, zipHash 918d13ac2821a3a17b2819f8861453e9. Upstream GitHub releases do not expose a matching 1.14.3 asset, so this remains a BeatMods CDN fallback."
[[plugins]]
id = "system-io-compression"
tag = "beatmods-4.6.57"
asset = "System.IO.Compression-4.6.57.zip"
sha256 = "9067ddaf52c077f38a27ace6180a8134ceaaf1ecba752d08de81b4cc0c13aa43"
install_strategy = "bsipa-zip"
reason = "BeatMods verified System.IO.Compression 4.6.57 for Beat Saber 1.44.1 as version id 1763, zipHash a4e9e26f61967e56168e08eecb01ab88. Use BeatMods CDN as a framework/library dependency payload."
[[plugins]]
id = "system-io-compression-filesystem"
tag = "beatmods-4.7.3056"
asset = "System.IO.Compression.FileSystem-4.7.3056.zip"
sha256 = "1b6f7a33a69980344717a37240ed80b022903a52968d4968f307cf754e3a03f7"
install_strategy = "bsipa-zip"
reason = "BeatMods verified System.IO.Compression.FileSystem 4.7.3056 for Beat Saber 1.44.1 as version id 1762, zipHash e19f6fd395d54de7bfcbbbe3084dea28. Use BeatMods CDN as a framework/library dependency payload."
[[plugins]]
id = "imagesharp"
repo = "SixLabors/ImageSharp"
tag = "beatmods-2.0.0"
asset = "ImageSharp-2.0.0.zip"
sha256 = "b2bf6d195f25e1298199a389e381ab42f163b284105ae6181259d74f2528301b"
install_strategy = "bsipa-zip"
reason = "BeatMods verified ImageSharp 2.0.0 for Beat Saber 1.44.1 as version id 1428, zipHash b642fec88b0f84a0643ebd401d08da35. Use BeatMods CDN as a framework/library dependency payload."
[[plugins]]
id = "beatsaberplaylistslib"
repo = "Meivyn/BeatSaberPlaylistsLib"
tag = "beatmods-1.7.2"
asset = "BeatSaberPlaylistsLib-1.7.2.zip"
sha256 = "dfb26cf90cb73834c405727418c0121d8546cad0403abd35a09437dab766bd32"
install_strategy = "bsipa-zip"
reason = "BeatMods verified BeatSaberPlaylistsLib 1.7.2 for Beat Saber 1.44.1 as version id 2175, zipHash a3418b75ed7294a3856f3eca12bbd672. Upstream GitHub exposes no release assets through the releases API, so this remains a BeatMods CDN fallback."
[[plugins]]
id = "beatsaversharp"
repo = "lolPants/BeatSaverSharp"
tag = "beatmods-3.4.5"
asset = "BeatSaverSharp-3.4.5.zip"
sha256 = "f5f37b27438e9b2fa1d9fbdf51a4f015f44ae04979cbdd9b90f6ae18583a6911"
install_strategy = "bsipa-zip"
reason = "BeatMods verified BeatSaverSharp 3.4.5 for Beat Saber 1.44.1 as version id 1831, zipHash be37e13e93d9ac7da4efbdc3f514fa8f. BeatMods upstream URL returned inaccessible via the GitHub releases API, so this remains a BeatMods CDN fallback."
[[plugins]]
id = "scoresabersharp"
tag = "beatmods-0.1.0"
asset = "ScoreSaberSharp-0.1.0.zip"
sha256 = "7f30be996f8f0e997f2d848e34de1d03ef6dc744ffc32d3fe505881ca22c6cd3"
install_strategy = "bsipa-zip"
reason = "BeatMods verified ScoreSaberSharp 0.1.0 for Beat Saber 1.44.1 as version id 445, zipHash 8713168c598577ee7c73fa3cf0e26f5c. BeatMods lists scoresaber.com rather than a GitHub release source, so this remains a BeatMods CDN fallback."
[[plugins]]
id = "beatsaverdownloader"
repo = "Top-Cat/BeatSaverDownloader"
tag = "beatmods-6.0.7"
asset = "BeatSaverDownloader-6.0.7.zip"
sha256 = "4108b11eae11d09f8c4e838dde1dc36108f1f3fd4fff31b951e7c551fa59f5f1"
install_strategy = "bsipa-zip"
reason = "BeatMods verified BeatSaverDownloader 6.0.7 for Beat Saber 1.44.1 as version id 2217, zipHash a740c6e68a9b5d1dfda3cc8e81f7cf06. Upstream GitHub exposes no release assets through the releases API, so this remains a BeatMods CDN fallback."
[[plugins]]
id = "beatsaverupdater"
repo = "ibillingsley/BeatSaverUpdater"
tag = "1.2.11"
asset = "BeatSaverUpdater-1.2.11-bs1.39.1-3698f98.zip"
sha256 = "6237da05cbc044e99211cb0e569c917fe61bb0da25e42c61a6d438371548ebba"
install_strategy = "bsipa-zip"
reason = "BeatMods verified BeatSaverUpdater 1.2.11 for Beat Saber 1.44.1 as version id 2352, zipHash d9ea8dd0cbaac66cbb02fa59a548e42b. GitHub asset is byte-identical to the BeatMods CDN zip."
+264
View File
@@ -21,3 +21,267 @@ repo = "not-dexter/accsaber-reloaded-plugin"
asset_patterns = ["1.40.8.zip"]
install_strategy = "bsipa-zip"
category = "leaderboard"
[[plugins]]
id = "bsipa"
name = "BSIPA"
repo = "nike4613/BeatSaber-IPA-Reloaded"
asset_patterns = ["BSIPA-net472-x64.zip"]
install_strategy = "root-zip"
category = "core"
[[plugins]]
id = "beatsabermarkuplanguage"
name = "BeatSaberMarkupLanguage"
repo = "monkeymanboy/BeatSaberMarkupLanguage"
asset_patterns = ["*RELEASE.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "sirautil"
name = "SiraUtil"
repo = "Auros/SiraUtil"
asset_patterns = ["SiraUtil-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "songcore"
name = "SongCore"
repo = "Kylemc1413/SongCore"
asset_patterns = ["SongCore-*.zip"]
install_strategy = "bsipa-zip"
category = "core"
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins.dependencies]]
id = "beatsabermarkuplanguage"
constraint = ">=1.14.1"
required = true
[[plugins.dependencies]]
id = "sirautil"
constraint = ">=3.3.1"
required = true
[[plugins]]
id = "customjsondata"
name = "CustomJSONData"
repo = "Aeroluna/CustomJSONData"
asset_patterns = ["CustomJSONData-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "iniparser"
name = "Ini Parser"
repo = "rickyah/ini-parser"
asset_patterns = ["IniParser-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins]]
id = "bs-utils"
name = "BS Utils"
repo = "Kylemc1413/Beat-Saber-Utils"
asset_patterns = ["BSUtils-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins.dependencies]]
id = "iniparser"
constraint = ">=2.5.9"
required = true
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "system-io-compression"
name = "System.IO.Compression"
asset_patterns = ["System.IO.Compression-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins]]
id = "system-io-compression-filesystem"
name = "System.IO.Compression.FileSystem"
asset_patterns = ["System.IO.Compression.FileSystem-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins.dependencies]]
id = "system-io-compression"
constraint = ">=4.6.57"
required = true
[[plugins]]
id = "imagesharp"
name = "ImageSharp"
repo = "SixLabors/ImageSharp"
asset_patterns = ["ImageSharp-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins]]
id = "beatsaberplaylistslib"
name = "BeatSaberPlaylistsLib"
repo = "Meivyn/BeatSaberPlaylistsLib"
asset_patterns = ["BeatSaberPlaylistsLib-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins.dependencies]]
id = "songcore"
constraint = ">=3.16.0"
required = true
[[plugins.dependencies]]
id = "imagesharp"
constraint = ">=2.0.0"
required = true
[[plugins.dependencies]]
id = "system-io-compression"
constraint = ">=4.6.57"
required = true
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "beatsaversharp"
name = "BeatSaverSharp"
repo = "lolPants/BeatSaverSharp"
asset_patterns = ["BeatSaverSharp-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins]]
id = "scoresabersharp"
name = "ScoreSaberSharp"
asset_patterns = ["ScoreSaberSharp-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins]]
id = "beatsaverdownloader"
name = "BeatSaverDownloader"
repo = "Top-Cat/BeatSaverDownloader"
asset_patterns = ["BeatSaverDownloader-*.zip"]
install_strategy = "bsipa-zip"
category = "downloader"
[[plugins.dependencies]]
id = "bs-utils"
constraint = ">=1.14.3"
required = true
[[plugins.dependencies]]
id = "beatsabermarkuplanguage"
constraint = ">=1.14.1"
required = true
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins.dependencies]]
id = "scoresabersharp"
constraint = ">=0.1.0"
required = true
[[plugins.dependencies]]
id = "system-io-compression-filesystem"
constraint = ">=4.7.3056"
required = true
[[plugins.dependencies]]
id = "songcore"
constraint = ">=3.16.0"
required = true
[[plugins.dependencies]]
id = "beatsaversharp"
constraint = ">=3.4.5"
required = true
[[plugins]]
id = "beatsaverupdater"
name = "BeatSaverUpdater"
repo = "ibillingsley/BeatSaverUpdater"
asset_patterns = ["BeatSaverUpdater-*.zip"]
install_strategy = "bsipa-zip"
category = "downloader"
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins.dependencies]]
id = "beatsabermarkuplanguage"
constraint = ">=1.14.1"
required = true
[[plugins.dependencies]]
id = "songcore"
constraint = ">=3.16.0"
required = true
[[plugins.dependencies]]
id = "sirautil"
constraint = ">=3.3.1"
required = true
[[plugins.dependencies]]
id = "beatsaversharp"
constraint = ">=3.4.5"
required = true
[[plugins]]
id = "setlist"
name = "Setlist"
asset_patterns = ["Setlist.dll"]
install_strategy = "dll-to-plugins"
category = "ui"
[[plugins.dependencies]]
id = "beatleader"
constraint = ">=0.9.0"
required = true
[[plugins.dependencies]]
id = "playlistmanager"
constraint = ">=1.7.0"
required = true
[[plugins.dependencies]]
id = "beatsaberplaylistslib"
constraint = ">=1.7.0"
required = true
+255
View File
@@ -0,0 +1,255 @@
from __future__ import annotations
import os
import signal
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from urllib.request import Request, urlopen
from .bsipa import BSIPA_PLUGIN_ID, check_bsipa_health
from .fsutil import sha256_file
from .installer import apply_plan
from .models import LockedPlugin, Lockfile, Registry
from .planner import create_plan
from .scanner import scan_bootstrap_files
from .state import bootstrap_state_path, plugin_downloads_dir, save_bootstrap_state
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _default_proton() -> Path:
return Path.home() / ".local/share/Steam/steamapps/common/Proton - Experimental/proton"
def _proton_env(instance_path: Path) -> dict[str, str]:
env = os.environ.copy()
env.update(
{
"SteamAppId": "620980",
"SteamOverlayGameId": "620980",
"SteamGameId": "620980",
"WINEDLLOVERRIDES": "winhttp=n,b",
"STEAM_COMPAT_DATA_PATH": str(Path.home() / ".local/share/BSManager/SharedContent/compatdata"),
"STEAM_COMPAT_INSTALL_PATH": str(instance_path),
"STEAM_COMPAT_CLIENT_INSTALL_PATH": str(Path.home() / ".local/share/Steam"),
"STEAM_COMPAT_APP_ID": "620980",
"SteamEnv": "1",
}
)
return env
def _files_delta(before: list[dict[str, Any]], after: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
before_by_path = {item["path"]: item for item in before}
after_by_path = {item["path"]: item for item in after}
created = [after_by_path[path] for path in sorted(after_by_path.keys() - before_by_path.keys())]
removed = [before_by_path[path] for path in sorted(before_by_path.keys() - after_by_path.keys())]
mutated = [
{
"path": path,
"before": before_by_path[path],
"after": after_by_path[path],
}
for path in sorted(before_by_path.keys() & after_by_path.keys())
if before_by_path[path].get("sha256") != after_by_path[path].get("sha256")
]
return {"created": created, "mutated": mutated, "removed": removed}
def _github_headers() -> dict[str, str]:
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "plugin-helper",
}
token = os.environ.get("GITHUB_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def _fetch_github_release_asset(locked: LockedPlugin, destination: Path) -> dict[str, Any]:
if not locked.repo or not locked.tag or not locked.asset:
raise ValueError("locked BSIPA entry needs repo, tag, and asset to fetch its archive")
release_url = f"https://api.github.com/repos/{locked.repo}/releases/tags/{locked.tag}"
request = Request(release_url, headers=_github_headers())
with urlopen(request, timeout=20) as response:
release = response.read().decode("utf-8")
import json
data = json.loads(release)
assets = data.get("assets", [])
selected = next((asset for asset in assets if asset.get("name") == locked.asset), None)
if not selected:
raise FileNotFoundError(f"{locked.id}: release {locked.tag} does not contain asset {locked.asset}")
download_url = selected.get("browser_download_url")
if not download_url:
raise ValueError(f"{locked.id}: GitHub asset has no browser_download_url")
destination.parent.mkdir(parents=True, exist_ok=True)
asset_request = Request(download_url, headers={"User-Agent": "plugin-helper"})
with urlopen(asset_request, timeout=60) as response:
destination.write_bytes(response.read())
actual_sha = sha256_file(destination)
if locked.sha256 and actual_sha != locked.sha256:
destination.unlink(missing_ok=True)
raise ValueError(f"{locked.id}: fetched asset sha256 mismatch")
return {
"repo": locked.repo,
"tag": locked.tag,
"asset": locked.asset,
"url": download_url,
"path": str(destination),
"sha256": actual_sha,
}
def fetch_locked_bsipa_archive(lockfile: Lockfile, state_root: Path) -> dict[str, Any]:
locked = next((plugin for plugin in lockfile.plugins if plugin.id == BSIPA_PLUGIN_ID), None)
if not locked:
raise ValueError("lockfile does not include a bsipa entry")
if not locked.asset:
raise ValueError("locked BSIPA entry has no asset")
destination = plugin_downloads_dir(state_root, lockfile.instance, BSIPA_PLUGIN_ID) / locked.asset
if destination.is_file():
actual_sha = sha256_file(destination)
if locked.sha256 and actual_sha != locked.sha256:
raise ValueError(f"{locked.id}: existing asset sha256 mismatch: {destination}")
return {
"asset": locked.asset,
"path": str(destination),
"sha256": actual_sha,
"cached": True,
}
result = _fetch_github_release_asset(locked, destination)
result["cached"] = False
return result
def _run_ipa(
*,
command: list[str],
instance_path: Path,
timeout_seconds: int,
) -> dict[str, Any]:
process = subprocess.Popen(
command,
cwd=instance_path,
env=_proton_env(instance_path),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
try:
stdout, stderr = process.communicate(timeout=timeout_seconds)
timed_out = False
except subprocess.TimeoutExpired:
timed_out = True
os.killpg(process.pid, signal.SIGTERM)
try:
stdout, stderr = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
stdout, stderr = process.communicate()
return {
"returncode": process.returncode,
"stdout": stdout,
"stderr": stderr,
"timedOut": timed_out,
"timeoutSeconds": timeout_seconds,
}
def run_bootstrap(
*,
instance: str,
instance_path: Path,
beat_saber_version: str,
registry: Registry,
lockfile: Lockfile,
state_root: Path,
repo_root: Path,
proton: Path | None = None,
progress: Callable[[str], None] | None = None,
ipa_timeout_seconds: int = 120,
) -> dict[str, Any]:
tell = progress or (lambda _message: None)
tell("Fetching locked BSIPA archive")
fetched = fetch_locked_bsipa_archive(lockfile, state_root)
if fetched.get("cached"):
tell(f"Using cached BSIPA archive: {fetched['path']}")
else:
tell(f"Downloaded BSIPA archive: {fetched['path']}")
tell("Scanning bootstrap files before install")
before = scan_bootstrap_files(instance_path)
tell("Creating BSIPA bootstrap plan")
plan, plan_path = create_plan(
instance=instance,
instance_path=instance_path,
beat_saber_version=beat_saber_version,
registry=registry,
lockfile=lockfile,
state_root=state_root,
repo_root=repo_root,
selected={BSIPA_PLUGIN_ID},
require_bootstrap=False,
)
if not plan["changes"]:
raise ValueError("BSIPA bootstrap plan has no changes")
tell(f"Applying BSIPA bootstrap plan with {len(plan['changes'])} file changes")
apply_result = apply_plan(plan, state_root)
ipa = instance_path / "IPA.exe"
if not ipa.is_file():
raise FileNotFoundError(f"BSIPA archive did not install IPA.exe: {ipa}")
proton_path = proton or _default_proton()
if not proton_path.is_file():
raise FileNotFoundError(f"Proton executable not found: {proton_path}")
command = [str(proton_path), "run", str(ipa), "-n"]
tell(f"Running IPA.exe -n through Proton; timeout {ipa_timeout_seconds}s")
completed = _run_ipa(
command=command,
instance_path=instance_path,
timeout_seconds=ipa_timeout_seconds,
)
tell("Scanning bootstrap files after IPA.exe -n")
after = scan_bootstrap_files(instance_path)
delta = _files_delta(before, after)
state = {
"schemaVersion": 1,
"instance": instance,
"beatSaberVersion": beat_saber_version,
"bootstrappedAt": _now_iso(),
"plugin": BSIPA_PLUGIN_ID,
"archive": fetched,
"planPath": str(plan_path),
"applied": apply_result["applied"],
"proton": str(proton_path),
"command": command,
"ipaExitCode": completed["returncode"],
"ipaTimedOut": completed["timedOut"],
"ipaTimeoutSeconds": completed["timeoutSeconds"],
"ipaStdout": completed["stdout"][-20000:],
"ipaStderr": completed["stderr"][-20000:],
"files": after,
"delta": delta,
"health": {},
}
save_bootstrap_state(state_root, instance, state)
state["health"] = check_bsipa_health(instance_path, state_root, instance)
save_bootstrap_state(state_root, instance, state)
state["statePath"] = str(bootstrap_state_path(state_root, instance))
if completed["timedOut"]:
raise TimeoutError(f"IPA.exe -n timed out after {ipa_timeout_seconds}s; state written to {state['statePath']}")
if completed["returncode"] != 0:
raise RuntimeError(f"IPA.exe -n failed with exit code {completed['returncode']}; state written to {state['statePath']}")
return state
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from .fsutil import sha256_file
from .state import bootstrap_state_path, load_bootstrap_state
BSIPA_PLUGIN_ID = "bsipa"
def latest_log_path(instance_path: Path) -> Path:
return instance_path / "Logs" / "_latest.log"
def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) -> dict[str, Any]:
state = load_bootstrap_state(state_root, instance)
messages: list[str] = []
required = ["IPA.exe", "winhttp.dll"]
for rel in required:
if not (instance_path / rel).is_file():
messages.append(f"missing {rel}")
for rel in ("IPA", "Libs"):
if not (instance_path / rel).is_dir():
messages.append(f"missing {rel}/")
log_path = latest_log_path(instance_path)
if not log_path.is_file():
messages.append("missing Logs/_latest.log")
else:
text = log_path.read_text(encoding="utf-8", errors="replace")
if "Beat Saber IPA (BSIPA):" not in text and "Beat Saber IPA" not in text:
messages.append("Logs/_latest.log does not show BSIPA startup")
if not state:
messages.append(f"missing bootstrap state: {bootstrap_state_path(state_root, instance)}")
return {
"ok": not messages,
"messages": messages,
"statePath": str(bootstrap_state_path(state_root, instance)),
"logPath": str(log_path),
"logSha256": sha256_file(log_path) if log_path.is_file() else None,
"bootstrapRecordedAt": state.get("updatedAt"),
}
+1 -1
View File
@@ -30,7 +30,7 @@ def check_lock(
if not locked.asset:
messages.append({"level": "error", "message": "missing asset"})
else:
asset_path = _find_asset(locked.asset, state_root, instance, repo_root)
asset_path = _find_asset(locked.asset, state_root, instance, repo_root, locked.id)
if not asset_path:
messages.append({"level": "error", "message": "asset not found in downloads or repo assets"})
elif locked.sha256 and sha256_file(asset_path) != locked.sha256:
+361 -16
View File
@@ -4,23 +4,122 @@ import argparse
import json
import sys
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 .github import fetch_releases
from .installer import apply_plan, uninstall_plugin
from .instances import get_instance, list_instances
from .models import load_lockfile, load_registry
from .models import Lockfile, Registry
from .planner import create_plan
from .scanner import scan_instance
from .state import load_installed_state
from .userdata import backup_userdata
from .updates import check_updates
from .userdata import sync_windows_data_repo
def _json(data: Any) -> None:
print(json.dumps(data, indent=2, sort_keys=True))
def installed_plugins_report(
*,
installed_state: dict[str, Any],
registry: Registry,
lockfile: Lockfile,
) -> dict[str, Any]:
locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins}
plugins: list[dict[str, Any]] = []
for plugin_id, plugin_state in sorted(installed_state.get("plugins", {}).items()):
registry_plugin = registry.get(plugin_id)
locked = locked_by_id.get(plugin_id)
files = plugin_state.get("files", [])
plugins.append(
{
"id": plugin_id,
"name": registry_plugin.name if registry_plugin else plugin_id,
"version": locked.tag if locked and locked.tag else "(not locked)",
"asset": locked.asset if locked and locked.asset else "(unknown)",
"repo": (locked.repo if locked and locked.repo else None)
or (registry_plugin.repo if registry_plugin else None)
or "(unknown)",
"installedAt": plugin_state.get("installedAt", "(unknown)"),
"fileCount": len(files),
"files": files,
}
)
return {
"instance": installed_state.get("instance", lockfile.instance),
"beatSaberVersion": installed_state.get("beatSaberVersion", lockfile.beat_saber_version),
"plugins": plugins,
}
def print_installed_plugins(report: dict[str, Any]) -> None:
plugins = report["plugins"]
print(f"{report['instance']} managed plugins ({len(plugins)})")
if not plugins:
print("No plugins have been installed by plugin-helper yet.")
return
headers = ("Plugin", "Version", "Asset", "Files", "Installed")
rows = [
(
f"{plugin['name']} ({plugin['id']})",
plugin["version"],
plugin["asset"],
str(plugin["fileCount"]),
plugin["installedAt"],
)
for plugin in plugins
]
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
header = " ".join(label.ljust(widths[index]) for index, label in enumerate(headers))
print(header)
print(" ".join("-" * width for width in widths))
for row in rows:
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
def print_updates(report: dict[str, Any]) -> None:
plugins = report["plugins"]
summary = report["summary"]
print(
f"{report['instance']} updates: "
f"{summary['updates']} available, {summary['current']} current, "
f"{summary['warnings']} warnings, {summary['errors']} errors"
)
if not plugins:
return
headers = ("Plugin", "Current", "Latest", "Asset", "Status")
rows = [
(
f"{plugin['name']} ({plugin['id']})",
plugin.get("currentTag") or "(none)",
plugin.get("latestTag") or "(unknown)",
plugin.get("latestAsset") or plugin.get("currentAsset") or "(unknown)",
plugin["status"],
)
for plugin in plugins
]
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
print(" ".join(label.ljust(widths[index]) for index, label in enumerate(headers)))
print(" ".join("-" * width for width in widths))
for row in rows:
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = False) -> None:
default = argparse.SUPPRESS if suppress_default else None
parser.add_argument("--instances-root", default=default, help="BSManager instances root")
@@ -38,6 +137,12 @@ def build_parser() -> argparse.ArgumentParser:
parents=[_common_parent()],
)
subcommands.add_parser(
"menu",
help="Open an interactive instance/action menu",
parents=[_common_parent()],
)
scan = subcommands.add_parser(
"scan",
help="Inspect installed Plugins, Libs, and IPA/Pending files",
@@ -54,6 +159,16 @@ def build_parser() -> argparse.ArgumentParser:
)
state.add_argument("--instance", required=True)
installed = subcommands.add_parser(
"installed",
help="List plugins installed by plugin-helper with locked release versions",
parents=[_common_parent()],
)
installed.add_argument("--instance", required=True)
installed.add_argument("--registry", default="registry/plugins.toml")
installed.add_argument("--lockfile")
installed.add_argument("--json", action="store_true", help="Print full JSON output")
check = subcommands.add_parser(
"check",
help="Validate local registry, lockfile, and release asset readiness",
@@ -64,6 +179,37 @@ def build_parser() -> argparse.ArgumentParser:
check.add_argument("--lockfile")
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",
help="Check GitHub for newer matching releases for locked plugins",
parents=[_common_parent()],
)
updates.add_argument("--instance", required=True)
updates.add_argument("--registry", default="registry/plugins.toml")
updates.add_argument("--lockfile")
updates.add_argument("--plugin", action="append", help="Check only this locked plugin id; repeatable")
updates.add_argument("--include-prerelease", action="store_true", help="Include prerelease GitHub releases")
updates.add_argument("--json", action="store_true", help="Print full JSON update output")
plan = subcommands.add_parser(
"plan",
help="Create a dry-run install plan from registry and lockfile",
@@ -92,10 +238,13 @@ def build_parser() -> argparse.ArgumentParser:
backup = subcommands.add_parser(
"backup-userdata",
help="Create a timestamped UserData backup archive",
help="Copy UserData and Windows AppData into the adjacent backups repo",
parents=[_common_parent()],
)
backup.add_argument("--instance", required=True)
backup.add_argument("--backup-root", default="../backups/beat-saber", help="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
@@ -106,17 +255,115 @@ def _common_parent() -> argparse.ArgumentParser:
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:
parser = build_parser()
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))
try:
if args.command == "instances":
found = list_instances(inst_root)
found = list_instances(inst_roots)
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
for item in found:
flags = []
@@ -130,8 +377,11 @@ def run(argv: list[str] | None = None) -> int:
print(f"{item.name}\t{item.path}{suffix}")
return 0
if args.command == "menu":
return _run_menu(inst_roots, st_root)
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)
if args.json:
_json(result)
@@ -147,6 +397,23 @@ def run(argv: list[str] | None = None) -> int:
_json(load_installed_state(st_root, args.instance))
return 0
if args.command == "installed":
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()
result = installed_plugins_report(
installed_state=load_installed_state(st_root, args.instance),
registry=load_registry(registry_path),
lockfile=load_lockfile(lock_path),
)
if args.json:
_json(result)
else:
print_installed_plugins(result)
return 0
if args.command == "check":
root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
@@ -176,8 +443,76 @@ def run(argv: list[str] | None = None) -> int:
print(f" {message['level']}: {message['message']}")
return 2 if result["summary"]["errors"] else 0
if args.command == "updates":
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()
result = check_updates(
registry=load_registry(registry_path),
lockfile=load_lockfile(lock_path),
fetch_releases=fetch_releases,
selected=set(args.plugin) if args.plugin else None,
include_prerelease=args.include_prerelease,
)
if args.json:
_json(result)
else:
print_updates(result)
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":
instance = get_instance(inst_root, args.instance)
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"
@@ -211,7 +546,7 @@ def run(argv: list[str] | None = None) -> int:
return 0
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)
print(f"Removed: {len(result['removed'])}")
if result["skipped"]:
@@ -221,12 +556,22 @@ def run(argv: list[str] | None = None) -> int:
return 0 if result["stateUpdated"] else 2
if args.command == "backup-userdata":
instance = get_instance(inst_root, args.instance)
result = backup_userdata(args.instance, instance.path, st_root)
manifest = result["manifest"]
print(f"Archive: {result['archive']}")
print(f"Files: {manifest['fileCount']}")
print(f"Bytes: {manifest['totalSize']}")
instance = get_instance(inst_roots, args.instance)
root = repo_root()
backup_root = Path(args.backup_root).expanduser()
if not backup_root.is_absolute():
backup_root = (root / backup_root).resolve()
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
except Exception as exc:
+11 -2
View File
@@ -4,12 +4,21 @@ import os
from pathlib import Path
DEFAULT_INSTANCES_ROOT = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances")
WINDOWS_INSTANCES_ROOT = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances")
LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances"
DEFAULT_INSTANCES_ROOTS = (WINDOWS_INSTANCES_ROOT, LOCAL_INSTANCES_ROOT)
DEFAULT_INSTANCES_ROOT = WINDOWS_INSTANCES_ROOT
def instances_root(value: str | None = None) -> Path:
return instances_roots(value)[0]
def instances_roots(value: str | None = None) -> list[Path]:
raw = value or os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT")
return Path(raw).expanduser() if raw else DEFAULT_INSTANCES_ROOT
if raw:
return [Path(item).expanduser() for item in raw.split(os.pathsep) if item]
return list(DEFAULT_INSTANCES_ROOTS)
def state_root(value: str | None = None) -> Path:
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
import json
import os
from typing import Any
from urllib.request import Request, urlopen
def fetch_releases(repo: str) -> list[dict[str, Any]]:
token = os.environ.get("GITHUB_TOKEN")
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "plugin-helper",
}
if token:
headers["Authorization"] = f"Bearer {token}"
request = Request(f"https://api.github.com/repos/{repo}/releases", headers=headers)
with urlopen(request, timeout=20) as response:
data = json.loads(response.read().decode("utf-8"))
if not isinstance(data, list):
raise ValueError(f"unexpected GitHub releases response for {repo}")
return data
+44 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from collections.abc import Sequence
@dataclass(frozen=True)
@@ -13,6 +14,9 @@ class Instance:
has_userdata: bool
RootInput = Path | Sequence[Path]
def looks_like_instance(path: Path) -> bool:
return (
(path / "Beat Saber_Data").is_dir()
@@ -22,7 +26,13 @@ def looks_like_instance(path: Path) -> bool:
)
def list_instances(root: Path) -> list[Instance]:
def _root_list(root: RootInput) -> list[Path]:
if isinstance(root, Path):
return [root]
return list(root)
def _list_instances_one(root: Path) -> list[Instance]:
if not root.exists():
return []
instances: list[Instance] = []
@@ -41,7 +51,20 @@ def list_instances(root: Path) -> list[Instance]:
return instances
def get_instance(root: Path, name: str) -> Instance:
def list_instances(root: RootInput) -> list[Instance]:
instances: list[Instance] = []
seen_paths: set[Path] = set()
for item in _root_list(root):
for instance in _list_instances_one(item):
resolved = instance.path.resolve(strict=False)
if resolved in seen_paths:
continue
seen_paths.add(resolved)
instances.append(instance)
return sorted(instances, key=lambda item: (item.name, str(item.path)))
def _get_instance_one(root: Path, name: str) -> Instance:
path = root / name
if not path.is_dir() or not looks_like_instance(path):
raise FileNotFoundError(f"Beat Saber instance not found: {path}")
@@ -52,3 +75,22 @@ def get_instance(root: Path, name: str) -> Instance:
has_libs=(path / "Libs").is_dir(),
has_userdata=(path / "UserData").is_dir(),
)
def get_instance(root: RootInput, name: str) -> Instance:
if isinstance(root, Path):
return _get_instance_one(root, name)
matches: list[Instance] = []
for item in _root_list(root):
try:
matches.append(_get_instance_one(item, name))
except FileNotFoundError:
continue
if not matches:
searched = ", ".join(str(item) for item in _root_list(root))
raise FileNotFoundError(f"Beat Saber instance not found: {name} under {searched}")
if len(matches) > 1:
paths = ", ".join(str(item.path) for item in matches)
raise ValueError(f"Beat Saber instance name is ambiguous: {name}; matches: {paths}")
return matches[0]
+18 -5
View File
@@ -9,7 +9,8 @@ from zipfile import ZipFile
from .fsutil import ensure_relative, sha256_bytes, sha256_file
from .models import Lockfile, Registry, VALID_STRATEGIES
from .state import downloads_dir, plans_dir
from .bsipa import BSIPA_PLUGIN_ID, check_bsipa_health
from .state import downloads_dir, plans_dir, plugin_downloads_dir
ALLOWED_BSIPA_TOP_LEVEL = {"IPA", "Libs", "Plugins"}
@@ -19,13 +20,15 @@ def _now_slug() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def _find_asset(asset: str, state_root: Path, instance: str, repo_root: Path) -> Path | None:
def _find_asset(asset: str, state_root: Path, instance: str, repo_root: Path, plugin_id: str | None = None) -> Path | None:
candidates = [
Path(asset).expanduser(),
downloads_dir(state_root, instance) / asset,
repo_root / "assets" / asset,
repo_root / "locks" / "assets" / asset,
]
if plugin_id:
candidates.insert(1, plugin_downloads_dir(state_root, instance, plugin_id) / asset)
candidates.insert(2 if plugin_id else 1, downloads_dir(state_root, instance) / asset)
for candidate in candidates:
if candidate.exists() and candidate.is_file():
return candidate
@@ -72,11 +75,20 @@ def create_plan(
state_root: Path,
repo_root: Path,
selected: set[str] | None = None,
require_bootstrap: bool = True,
) -> tuple[dict[str, Any], Path]:
selected_ids = selected or {plugin.id for plugin in lockfile.plugins}
changes: list[dict[str, Any]] = []
warnings: list[str] = []
has_locked_bsipa = any(plugin.id == BSIPA_PLUGIN_ID for plugin in lockfile.plugins)
planning_ordinary_plugins = any(plugin_id != BSIPA_PLUGIN_ID for plugin_id in selected_ids)
if require_bootstrap and has_locked_bsipa and planning_ordinary_plugins:
health = check_bsipa_health(instance_path, state_root, instance)
if not health["ok"]:
joined = "; ".join(health["messages"])
raise ValueError(f"BSIPA bootstrap is not healthy; run bootstrap first: {joined}")
for locked in lockfile.plugins:
if locked.id not in selected_ids:
continue
@@ -91,10 +103,11 @@ def create_plan(
if registry_plugin and not _asset_matches_patterns(Path(locked.asset).name, registry_plugin.asset_patterns):
warnings.append(f"{locked.id}: asset does not match registry patterns")
asset_path = _find_asset(locked.asset, state_root, instance, repo_root)
asset_path = _find_asset(locked.asset, state_root, instance, repo_root, locked.id)
if not asset_path:
raise FileNotFoundError(
f"{locked.id}: asset not found: {locked.asset}; put it in {downloads_dir(state_root, instance)}"
f"{locked.id}: asset not found: {locked.asset}; put it in "
f"{plugin_downloads_dir(state_root, instance, locked.id)}"
)
asset_sha = sha256_file(asset_path)
if locked.sha256 and locked.sha256 != asset_sha:
+31
View File
@@ -7,6 +7,8 @@ from .fsutil import sha256_file
SCAN_DIRS = ("Plugins", "Libs", "IPA/Pending")
BOOTSTRAP_DIRS = ("Libs", "IPA")
BOOTSTRAP_ROOT_GLOBS = ("winhttp.dll", "IPA.exe*")
def scan_instance(instance_path: Path, include_hashes: bool = False) -> dict[str, Any]:
@@ -31,3 +33,32 @@ def scan_instance(instance_path: Path, include_hashes: bool = False) -> dict[str
"pending": sum(1 for item in files if item["path"].startswith("IPA/Pending/")),
},
}
def scan_bootstrap_files(instance_path: Path) -> list[dict[str, Any]]:
files_by_path: dict[str, dict[str, Any]] = {}
for pattern in BOOTSTRAP_ROOT_GLOBS:
for path in sorted(instance_path.glob(pattern)):
if not path.is_file():
continue
rel = path.relative_to(instance_path).as_posix()
files_by_path[rel] = {
"path": rel,
"size": path.stat().st_size,
"sha256": sha256_file(path),
}
for dirname in BOOTSTRAP_DIRS:
root = instance_path / dirname
if not root.exists():
continue
for path in sorted(item for item in root.rglob("*") if item.is_file()):
rel = path.relative_to(instance_path).as_posix()
files_by_path[rel] = {
"path": rel,
"size": path.stat().st_size,
"sha256": sha256_file(path),
}
return [files_by_path[path] for path in sorted(files_by_path)]
+20
View File
@@ -15,6 +15,10 @@ def installed_state_path(state_root: Path, instance: str) -> Path:
return instance_state_dir(state_root, instance) / "installed.json"
def bootstrap_state_path(state_root: Path, instance: str) -> Path:
return instance_state_dir(state_root, instance) / "bootstrap.json"
def load_installed_state(state_root: Path, instance: str) -> dict[str, Any]:
return read_json(
installed_state_path(state_root, instance),
@@ -22,6 +26,16 @@ def load_installed_state(state_root: Path, instance: str) -> dict[str, Any]:
)
def load_bootstrap_state(state_root: Path, instance: str) -> dict[str, Any]:
return read_json(bootstrap_state_path(state_root, instance), {})
def save_bootstrap_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
state.setdefault("instance", instance)
state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
atomic_write_json(bootstrap_state_path(state_root, instance), state)
def save_installed_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
state.setdefault("instance", instance)
state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
@@ -40,6 +54,12 @@ def downloads_dir(state_root: Path, instance: str) -> Path:
return path
def plugin_downloads_dir(state_root: Path, instance: str, plugin_id: str) -> Path:
path = downloads_dir(state_root, instance) / plugin_id
path.mkdir(parents=True, exist_ok=True)
return path
def backups_dir(state_root: Path, instance: str) -> Path:
path = instance_state_dir(state_root, instance) / "backups"
path.mkdir(parents=True, exist_ok=True)
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
import fnmatch
import re
from typing import Any, Callable
from .models import Lockfile, Registry
FetchReleases = Callable[[str], list[dict[str, Any]]]
def _release_tag(release: dict[str, Any]) -> str:
return str(release.get("tag_name") or "")
def _release_assets(release: dict[str, Any]) -> list[dict[str, Any]]:
assets = release.get("assets") or []
return [asset for asset in assets if isinstance(asset, dict)]
def _asset_name(asset: dict[str, Any]) -> str:
return str(asset.get("name") or "")
def _semver_key(tag: str) -> tuple[int, tuple[int, ...], str]:
match = re.search(r"(\d+(?:\.\d+){0,3})", tag)
if not match:
return (0, (), tag)
return (1, tuple(int(part) for part in match.group(1).split(".")), tag)
def _sorted_releases(releases: list[dict[str, Any]], include_prerelease: bool) -> list[dict[str, Any]]:
candidates = [
release
for release in releases
if not release.get("draft") and (include_prerelease or not release.get("prerelease"))
]
return sorted(
candidates,
key=lambda release: (
str(release.get("published_at") or release.get("created_at") or ""),
_semver_key(_release_tag(release)),
),
reverse=True,
)
def _matching_assets(
release: dict[str, Any],
*,
asset_patterns: tuple[str, ...],
current_asset: str | None,
beat_saber_version: str,
) -> list[dict[str, Any]]:
assets = _release_assets(release)
if asset_patterns:
assets = [
asset
for asset in assets
if any(fnmatch.fnmatch(_asset_name(asset), pattern) for pattern in asset_patterns)
]
if not assets:
return []
exact_version = [asset for asset in assets if _asset_name(asset) == f"{beat_saber_version}.zip"]
if exact_version:
return exact_version
if current_asset:
same_name = [asset for asset in assets if _asset_name(asset) == current_asset]
if same_name:
return same_name
contains_version = [asset for asset in assets if beat_saber_version in _asset_name(asset)]
return contains_version or assets
def _find_latest_matching_release(
releases: list[dict[str, Any]],
*,
asset_patterns: tuple[str, ...],
current_asset: str | None,
beat_saber_version: str,
include_prerelease: bool,
) -> tuple[dict[str, Any], dict[str, Any]] | None:
for release in _sorted_releases(releases, include_prerelease):
assets = _matching_assets(
release,
asset_patterns=asset_patterns,
current_asset=current_asset,
beat_saber_version=beat_saber_version,
)
if assets:
return release, assets[0]
return None
def check_updates(
*,
registry: Registry,
lockfile: Lockfile,
fetch_releases: FetchReleases,
selected: set[str] | None = None,
include_prerelease: bool = False,
) -> dict[str, Any]:
selected_ids = selected or {plugin.id for plugin in lockfile.plugins}
plugins: list[dict[str, Any]] = []
summary = {"current": 0, "updates": 0, "warnings": 0, "errors": 0}
for locked in lockfile.plugins:
if locked.id not in selected_ids:
continue
registry_plugin = registry.get(locked.id)
repo = locked.repo or (registry_plugin.repo if registry_plugin else None)
entry: dict[str, Any] = {
"id": locked.id,
"name": registry_plugin.name if registry_plugin else locked.id,
"repo": repo,
"currentTag": locked.tag,
"currentAsset": locked.asset,
"currentSha256": locked.sha256,
"latestTag": None,
"latestAsset": None,
"latestAssetSha256": None,
"status": "unknown",
"messages": [],
}
if not repo:
entry["status"] = "warning"
entry["messages"].append("missing repository")
summary["warnings"] += 1
plugins.append(entry)
continue
try:
releases = fetch_releases(repo)
except Exception as exc:
entry["status"] = "error"
entry["messages"].append(str(exc))
summary["errors"] += 1
plugins.append(entry)
continue
match = _find_latest_matching_release(
releases,
asset_patterns=registry_plugin.asset_patterns if registry_plugin else (),
current_asset=locked.asset,
beat_saber_version=lockfile.beat_saber_version,
include_prerelease=include_prerelease,
)
if not match:
entry["status"] = "warning"
entry["messages"].append("no matching release asset found")
summary["warnings"] += 1
plugins.append(entry)
continue
latest_release, latest_asset = match
entry["latestTag"] = _release_tag(latest_release)
entry["latestAsset"] = _asset_name(latest_asset)
entry["latestAssetSha256"] = str(latest_asset.get("digest") or "").removeprefix("sha256:") or None
entry["latestUrl"] = latest_release.get("html_url")
entry["latestAssetUrl"] = latest_asset.get("browser_download_url")
same_release = locked.tag == entry["latestTag"] and locked.asset == entry["latestAsset"]
same_hash = not entry["latestAssetSha256"] or locked.sha256 == entry["latestAssetSha256"]
if same_release and same_hash:
entry["status"] = "current"
summary["current"] += 1
else:
entry["status"] = "update"
summary["updates"] += 1
plugins.append(entry)
return {
"instance": lockfile.instance,
"beatSaberVersion": lockfile.beat_saber_version,
"includePrerelease": include_prerelease,
"summary": summary,
"plugins": plugins,
}
+118 -1
View File
@@ -1,16 +1,42 @@
from __future__ import annotations
import json
import fnmatch
import shutil
import tarfile
from datetime import datetime, timezone
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Any
from typing import Any, Callable
from .fsutil import sha256_file
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]:
source = instance_path / "UserData"
if not source.is_dir():
@@ -44,3 +70,94 @@ def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dic
handle.flush()
archive.add(handle.name, arcname="manifest.json")
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)
+462 -6
View File
@@ -1,19 +1,26 @@
from __future__ import annotations
import json
import tempfile
import unittest
import os
from io import StringIO
from pathlib import Path
from unittest.mock import patch
from zipfile import ZipFile
from plugin_helper.bootstrap import _run_ipa
from plugin_helper.checker import check_lock
from plugin_helper.cli import installed_plugins_report, run
from plugin_helper.fsutil import sha256_file
from plugin_helper.installer import apply_plan, uninstall_plugin
from plugin_helper.instances import get_instance, list_instances
from plugin_helper.models import Lockfile, LockedPlugin, Registry, RegistryPlugin
from plugin_helper.planner import create_plan
from plugin_helper.scanner import scan_instance
from plugin_helper.state import downloads_dir, load_installed_state
from plugin_helper.userdata import backup_userdata
from plugin_helper.scanner import scan_bootstrap_files, scan_instance
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.userdata import backup_userdata, infer_windows_appdata_path, sync_windows_data_repo
class PluginHelperTests(unittest.TestCase):
@@ -37,6 +44,87 @@ class PluginHelperTests(unittest.TestCase):
self.assertEqual(scan["files"][0]["path"], "Plugins/Example.dll")
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:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
@@ -46,7 +134,7 @@ class PluginHelperTests(unittest.TestCase):
(instance / "Beat Saber_Data").mkdir()
(instance / "Plugins").mkdir()
asset = downloads_dir(state, "1.40.8") / "Example.dll"
asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll"
asset.write_bytes(b"managed dll")
registry = Registry(
@@ -103,7 +191,7 @@ class PluginHelperTests(unittest.TestCase):
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
asset = downloads_dir(state, "1.40.8") / "Example.zip"
asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.zip"
with ZipFile(asset, "w") as archive:
archive.writestr("Plugins/Example.dll", b"dll")
@@ -144,6 +232,49 @@ class PluginHelperTests(unittest.TestCase):
apply_plan(plan, state)
self.assertEqual((instance / "IPA" / "Pending" / "Plugins" / "Example.dll").read_bytes(), b"dll")
def test_plan_still_finds_legacy_flat_downloads(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.40.8"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
asset = downloads_dir(state, "1.40.8") / "Example.dll"
asset.write_bytes(b"legacy flat download")
plan, _ = create_plan(
instance="1.40.8",
instance_path=instance,
beat_saber_version="1.40.8",
registry=Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo=None,
install_strategy="dll-to-plugins",
)
}
),
lockfile=Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="example",
repo=None,
tag=None,
asset="Example.dll",
sha256=sha256_file(asset),
),
),
),
state_root=state,
repo_root=work,
)
self.assertEqual(plan["changes"][0]["source"], str(asset))
def test_zip_member_cannot_escape_instance(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
@@ -151,7 +282,7 @@ class PluginHelperTests(unittest.TestCase):
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
asset = downloads_dir(state, "1.40.8") / "Bad.zip"
asset = plugin_downloads_dir(state, "1.40.8", "bad") / "Bad.zip"
with ZipFile(asset, "w") as archive:
archive.writestr("../Bad.dll", b"dll")
@@ -190,6 +321,103 @@ class PluginHelperTests(unittest.TestCase):
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:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
@@ -202,6 +430,56 @@ class PluginHelperTests(unittest.TestCase):
self.assertTrue(Path(result["archive"]).exists())
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:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
@@ -239,6 +517,184 @@ class PluginHelperTests(unittest.TestCase):
self.assertEqual(result["summary"]["errors"], 1)
self.assertEqual(result["plugins"][0]["status"], "error")
def test_installed_plugins_report_includes_locked_version(self) -> None:
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example Plugin",
repo="owner/example",
install_strategy="dll-to-plugins",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="example",
repo="owner/example",
tag="v1.2.3",
asset="Example.dll",
sha256="abc123",
),
),
)
report = installed_plugins_report(
installed_state={
"instance": "1.40.8",
"plugins": {
"example": {
"installedAt": "2026-06-14T17:18:40Z",
"files": [{"path": "Plugins/Example.dll"}],
}
},
},
registry=registry,
lockfile=lockfile,
)
self.assertEqual(report["plugins"][0]["name"], "Example Plugin")
self.assertEqual(report["plugins"][0]["version"], "v1.2.3")
self.assertEqual(report["plugins"][0]["asset"], "Example.dll")
self.assertEqual(report["plugins"][0]["fileCount"], 1)
def test_update_check_reports_current_matching_asset(self) -> None:
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo="owner/example",
asset_patterns=("1.40.8.zip",),
install_strategy="bsipa-zip",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="example",
repo="owner/example",
tag="v1.1.0",
asset="1.40.8.zip",
sha256="abc123",
),
),
)
result = check_updates(
registry=registry,
lockfile=lockfile,
fetch_releases=lambda repo: [
{
"tag_name": "v1.1.0",
"published_at": "2026-06-10T00:00:00Z",
"assets": [{"name": "1.40.8.zip", "digest": "sha256:abc123"}],
}
],
)
self.assertEqual(result["summary"]["current"], 1)
self.assertEqual(result["plugins"][0]["status"], "current")
self.assertEqual(result["plugins"][0]["latestAssetSha256"], "abc123")
def test_update_check_reports_new_matching_release(self) -> None:
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo="owner/example",
asset_patterns=("*.zip",),
install_strategy="bsipa-zip",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="example",
repo="owner/example",
tag="v1.1.0",
asset="1.40.8.zip",
sha256="abc123",
),
),
)
result = check_updates(
registry=registry,
lockfile=lockfile,
fetch_releases=lambda repo: [
{
"tag_name": "v1.2.0",
"published_at": "2026-06-12T00:00:00Z",
"assets": [
{"name": "1.29.1.zip"},
{"name": "1.40.8.zip", "browser_download_url": "https://example.invalid/asset"},
],
},
{
"tag_name": "v1.1.0",
"published_at": "2026-06-10T00:00:00Z",
"assets": [{"name": "1.40.8.zip"}],
},
],
)
self.assertEqual(result["summary"]["updates"], 1)
self.assertEqual(result["plugins"][0]["status"], "update")
self.assertEqual(result["plugins"][0]["latestTag"], "v1.2.0")
self.assertEqual(result["plugins"][0]["latestAsset"], "1.40.8.zip")
def test_update_check_reports_replaced_asset_digest(self) -> None:
registry = Registry(
{
"example": RegistryPlugin(
id="example",
name="Example",
repo="owner/example",
asset_patterns=("1.40.8.zip",),
install_strategy="bsipa-zip",
)
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(
id="example",
repo="owner/example",
tag="v1.1.0",
asset="1.40.8.zip",
sha256="old",
),
),
)
result = check_updates(
registry=registry,
lockfile=lockfile,
fetch_releases=lambda repo: [
{
"tag_name": "v1.1.0",
"published_at": "2026-06-10T00:00:00Z",
"assets": [{"name": "1.40.8.zip", "digest": "sha256:new"}],
}
],
)
self.assertEqual(result["summary"]["updates"], 1)
self.assertEqual(result["plugins"][0]["status"], "update")
self.assertEqual(result["plugins"][0]["latestAssetSha256"], "new")
if __name__ == "__main__":
unittest.main()