Compare commits

..

10 Commits

Author SHA1 Message Date
pleb 6117173507 Split Beat Saber plugin agent skills 2026-06-29 10:57:11 -07:00
pleb e0616de6c3 Record expanded 1.44.1 plugin set 2026-06-29 10:57:04 -07:00
pleb 1f35f6b078 Add BeatMods parsing and userdata restore 2026-06-29 10:56:57 -07:00
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
30 changed files with 4612 additions and 231 deletions
@@ -0,0 +1,94 @@
---
name: beatsaber-plugin-builder
description: Build, test-compile, or package Beat Saber PC BSIPA plugin source on Linux from local checkouts, GitHub branches, or pull requests. Use when asked to build a Beat Saber plugin, compile a plugin PR, produce a DLL/zip artifact, configure BeatSaberDir/local refs for dotnet builds, or diagnose Linux build failures for net472 BSIPA projects.
---
# Build Beat Saber Plugin
Use this skill to compile PC BSIPA plugin projects on this Linux host, especially from the `plugin-helper` repo. The workflow is adapted from the Setlist repo's Linux/Cursor build notes.
For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-bsipa-build.md) when you need to configure a project, fix missing references, package artifacts, or explain a failure.
## Core Workflow
1. Confirm context.
```bash
pwd
git status --short
```
In `plugin-helper`, run commands from repo root and keep temporary source checkouts under `.state/build/<name>` unless the user asks for another location. Do not disturb unrelated dirty files.
2. Resolve source.
For a GitHub PR, clone or reuse a checkout under `.state/build`, add/fetch the upstream remote if needed, and check out the PR head:
```bash
git clone https://github.com/<owner>/<repo>.git .state/build/<name>
git -C .state/build/<name> fetch origin pull/<pr>/head:pr-<pr>
git -C .state/build/<name> checkout pr-<pr>
```
If the PR is from a fork and the repo already has a fork remote, preserve it. Never overwrite local source changes without explicit approval.
3. Inspect build shape.
```bash
find <checkout> -maxdepth 3 -name '*.sln' -o -name '*.csproj' -o -name 'manifest.json' -o -name 'Directory.Build.props'
sed -n '1,240p' <project>.csproj
```
Note target framework, `BeatSaberDir`, `LocalRefsDir`, package references, and whether `DisableCopyToPlugins` is already set.
4. Choose Beat Saber references.
Prefer a BSManager instance matching the plugin or manifest `gameVersion`. Common local roots:
```text
/home/pleb/.local/share/BSManager/BSInstances/<version>
/home/pleb/Windows/Users/pleb/BSManager/BSInstances/<version>
```
Use a local `.csproj.user` or MSBuild properties rather than committing machine paths. For test builds, pass `-p:DisableCopyToPlugins=True` and, for BSMT projects that expose it, `-p:DisableCopyToGame=True` so compilation does not mutate the game install.
5. Restore and build.
Prefer the solution when present; otherwise build the plugin `.csproj`:
```bash
dotnet restore <solution-or-project>
dotnet build <solution-or-project> -c Release -p:DisableCopyToPlugins=True -p:DisableCopyToGame=True
```
If the project lacks .NET Framework reference assemblies on Linux, add or pass `Microsoft.NETFramework.ReferenceAssemblies.net472` as described in the reference file.
6. Collect artifacts.
Find produced DLLs and release zips:
```bash
find <checkout> -path '*/bin/*' \( -name '*.dll' -o -name '*.zip' \) -print
```
Verify the DLL name, version, and manifest. If the result is meant for `plugin-helper`, place a copy under `.state/instances/<instance>/downloads/<plugin-id>/` and use the helper plan/apply workflow rather than hand-copying into a BSManager instance.
7. Validate.
For skill edits inside this repo, run:
```bash
python /home/pleb/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/beatsaber-plugin-builder
PYTHONPATH=src python -m compileall -q src tests
PYTHONPATH=src python -m unittest discover -s tests
```
For a plugin build, at minimum report the exact `dotnet build` result and artifact paths. For live game validation, use `docs/SMOKETEST.md` and tear down Beat Saber processes afterward.
## Failure Triage
- Missing `Microsoft.NETFramework.ReferenceAssemblies`: add the net472 reference-assemblies package or pass an equivalent MSBuild/package restore fix.
- Missing `Main.dll`, `HMUI.dll`, `IPA.Loader.dll`, `BSML.dll`, `SongCore.dll`, or similar: `BeatSaberDir` points at the wrong/unmodded instance, or dependencies are absent from `Plugins/`/`Libs/`.
- BSMT copies to `IPA/Pending` or `Plugins` during build: rebuild with `-p:DisableCopyToPlugins=True -p:DisableCopyToGame=True` unless the user explicitly wants deployment.
- NuGet package restore fails because a source is missing: inspect `NuGet.config` and installed package sources; use repo-local configuration where possible.
- API compile errors from a PR: inspect the target game/dependency versions before changing code. Prefer one target version rather than compatibility branches unless the user asks for multi-version support.
@@ -0,0 +1,4 @@
interface:
display_name: "Build Beat Saber Plugin"
short_description: "Build BSIPA plugins on Linux"
default_prompt: "Use $beatsaber-plugin-builder to build this Beat Saber plugin PR on Linux."
@@ -0,0 +1,131 @@
# Linux BSIPA Build Reference
## Source Notes
This workflow comes from `/home/pleb/ops/beatsaber/setlist/docs/pc-modding.md` and `/home/pleb/ops/beatsaber/setlist/docs/bootstrap.md`. Prefer those local files and local clones under `~/src` over web copies when deeper reference material is needed.
## Toolchain
- PC BSIPA plugins are usually .NET Framework `net472` class libraries.
- Linux `dotnet` SDK 6+ can build them because output DLLs are platform-agnostic CIL loaded by Beat Saber under Proton.
- `BeatSaberModdingTools.Tasks` supplies the MSBuild targets normally driven by Visual Studio/Rider BSMT extensions.
- On this host, `dotnet --list-sdks` should show a usable SDK. NuGet is available for package inspection.
## Game References
Point `BeatSaberDir` at a modded BSManager instance containing:
```text
Beat Saber.exe
Beat Saber_Data/Managed/
IPA/
Libs/
Plugins/
winhttp.dll
```
Preferred local managed instance root:
```text
/home/pleb/.local/share/BSManager/BSInstances/<version>
```
Windows mirror root:
```text
/home/pleb/Windows/Users/pleb/BSManager/BSInstances/<version>
```
Use `BeatSaberVersion.txt` for the exact game version. The manifest `gameVersion` normally uses the `major.minor.patch` prefix, not the build suffix.
## Project Configuration
Prefer machine-local configuration in `<Project>.csproj.user`:
```xml
<Project>
<PropertyGroup>
<BeatSaberDir>/home/pleb/.local/share/BSManager/BSInstances/1.40.8</BeatSaberDir>
</PropertyGroup>
</Project>
```
Some projects use `LocalRefsDir` and set:
```xml
<BeatSaberDir>$(LocalRefsDir)</BeatSaberDir>
```
For those, pass `-p:LocalRefsDir=/path/to/instance` or add a local `.csproj.user` property if the project imports it.
When changing a project file for Linux portability, prefer the smallest explicit fix:
- Add `Microsoft.NETFramework.ReferenceAssemblies.net472` when MSBuild reports missing .NET Framework reference assemblies.
- Set or pass `DisableCopyToPlugins=True` for artifact-only builds.
- Keep hint paths rooted at `$(BeatSaberDir)` where possible.
- Do not add broad multi-version compatibility logic unless requested.
## Build Commands
From the plugin checkout:
```bash
dotnet restore <solution-or-project>
dotnet build <solution-or-project> -c Release -p:DisableCopyToPlugins=True
```
For a Debug smoke build:
```bash
dotnet build <solution-or-project> -c Debug -p:DisableCopyToPlugins=True
```
Expected outputs:
```text
bin/Debug/<Plugin>.dll
bin/Release/<Plugin>.dll
bin/Release/zip/<Plugin>-<version>.zip
bin/<Configuration>/Artifact/Plugins/<Plugin>.dll
```
The exact layout depends on BSMT version and project customization.
## BSMT Copy Behavior
`BeatSaberModdingTools.Tasks` may copy built DLLs into the game directory. On Unix hosts, its process check can behave differently and some projects add a custom target to copy into `<BeatSaberDir>/Plugins/`.
For builds that should not alter a live instance, pass both property names; different BSMT projects/targets surface different copy switches:
```bash
-p:DisableCopyToPlugins=True -p:DisableCopyToGame=True
```
If the purpose is to install the built artifact, copy it into plugin-helper's state downloads and use `plugin-helper plan/apply`, or follow `docs/SMOKETEST.md` for intentional live validation.
## Common Reference Failures
- `MSB3644` or missing `.NETFramework,Version=v4.7.2` reference assemblies: add `Microsoft.NETFramework.ReferenceAssemblies.net472`.
- Missing game assemblies such as `Main.dll`, `HMUI.dll`, `UnityEngine.CoreModule.dll`: `BeatSaberDir` is wrong or incomplete.
- Missing mod dependencies such as `BSML.dll`, `SongCore.dll`, `SiraUtil.dll`, `BeatSaberPlaylistsLib.dll`: install or point at an instance containing those plugins, or fetch the dependency DLL from its verified release only when appropriate.
- `IPA.Loader.dll` missing: BSIPA is not bootstrapped in that instance.
## Artifact Handoff To plugin-helper
For a built plugin DLL intended for a managed instance:
```bash
mkdir -p .state/instances/<instance>/downloads/<plugin-id>
cp <checkout>/<path>/bin/Release/<Plugin>.dll .state/instances/<instance>/downloads/<plugin-id>/<Plugin>.dll
sha256sum .state/instances/<instance>/downloads/<plugin-id>/<Plugin>.dll
```
Then update registry/lock data only if the user asked to manage/install the artifact, and use:
```bash
PYTHONPATH=src python -m plugin_helper --state-dir .state check --instance <instance>
PYTHONPATH=src python -m plugin_helper --state-dir .state plan --instance <instance> --plugin <plugin-id>
PYTHONPATH=src python -m plugin_helper --state-dir .state apply <plan-path>
```
Inspect the generated plan before applying.
@@ -0,0 +1,268 @@
---
name: beatsaber-plugin-manager
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 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
For ordinary GitHub-hosted plugins, require an explicit GitHub repository or
release URL from the user's prompt or from a user-provided local planning note
before selecting any release.
- If the user has provided a GitHub repository URL but not a release URL, use
that exact repository's release API and choose the most appropriate
non-draft, non-prerelease release/asset for the target Beat Saber instance.
- If the user has provided no GitHub repository or release URL for the plugin,
stop and ask the user for one.
- Do not search the web to discover a repository or "correct" project URL.
- Do not substitute a similar repo, fork, project, or package name.
- 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
https://github.com/<owner>/<repo>/releases
https://github.com/<owner>/<repo>/releases/tag/<tag>
https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
```
## Workflow
1. Confirm the workspace is the `plugin-helper` repo.
```bash
test -f pyproject.toml && test -d src/plugin_helper && test -d registry && test -d locks
```
2. Read the local helper behavior before changing files.
Inspect at least:
```bash
sed -n '1,220p' README.md
sed -n '1,260p' src/plugin_helper/cli.py
sed -n '1,260p' src/plugin_helper/planner.py
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.
Prefer the instance the user names. If omitted and the working context clearly points at one lockfile, use that instance. Otherwise run:
```bash
PYTHONPATH=src python -m plugin_helper instances
```
4. Resolve the release source.
For BeatMods bootstrap or verified packages, query BeatMods with a browser-like user agent:
```bash
PYTHONPATH=src python - <<'PY'
import json, urllib.request
from plugin_helper.beatmods import by_version_id, normalize_mods
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 = normalize_mods(data)
mods_by_version_id = by_version_id(mods)
print(json.dumps(
[
{
"name": mod.name,
"modId": mod.mod_id,
"gitUrl": mod.git_url,
"category": mod.category,
"versionId": mod.version_id,
"modVersion": mod.mod_version,
"zipHash": mod.zip_hash,
"dependencies": mod.dependencies,
"dependencyNames": [
mods_by_version_id[dep].name
for dep in mod.dependencies
if dep in mods_by_version_id
],
}
for mod in mods
],
indent=2,
)[:20000])
PY
```
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:
```text
https://beatmods.com/cdn/mod/<zipHash>.zip
```
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 repository or
release 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
curl -sS https://api.github.com/repos/<owner>/<repo>/releases/tags/<tag>
```
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.
5. Inspect the asset before selecting an install strategy.
Download to the helper state directory:
```bash
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/<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. 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.
6. Update the registry and lockfile.
Add or update exactly one `[[plugins]]` entry in `registry/plugins.toml` with:
```toml
[[plugins]]
id = "<stable-plugin-id>"
name = "<human name>"
repo = "<owner>/<repo>"
asset_patterns = ["<asset-name-or-pattern>"]
install_strategy = "<strategy>"
category = "<category-if-obvious>"
```
Add or update the matching `[[plugins]]` entry in `locks/<instance>.lock.toml` with:
```toml
[[plugins]]
id = "<stable-plugin-id>"
repo = "<owner>/<repo>"
tag = "<tag>"
asset = "<asset-name>"
sha256 = "<asset-sha256>"
```
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.
7. Use the helper to validate, plan, and apply.
Always pass `--state-dir .state` so the helper uses the repo-local downloaded asset:
```bash
PYTHONPATH=src python -m plugin_helper --state-dir .state check --instance <instance>
PYTHONPATH=src python -m plugin_helper --state-dir .state plan --instance <instance> --plugin <plugin-id>
PYTHONPATH=src python -m plugin_helper --state-dir .state apply <generated-plan-path>
```
Before applying, read or summarize the generated plan enough to confirm it changes only the intended plugin files.
8. Verify the result.
Confirm the installed file hashes match the plan or archive members:
```bash
PYTHONPATH=src python -m plugin_helper --state-dir .state state --instance <instance>
PYTHONPATH=src python -m plugin_helper --state-dir .state check --instance <instance>
PYTHONPATH=src python -m unittest discover -s tests
```
Use `PYTHONPATH=src`; plain `python -m unittest` may fail in this source-layout repo.
After any successful apply that changes a live BSManager instance, always
run the documented live smoketest before the final response unless the user
explicitly says not to. Do not stop at helper check, unit tests, compile
checks, or file-hash verification for live installs. For live Beat Saber
validation, follow `docs/SMOKETEST.md`. Before starting the launch, announce
in agent chat how long the smoketest window will run for, using the current
duration from `docs/SMOKETEST.md` unless the user requested a different
duration. 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 and
enough menu/UI initialization evidence for the plugin under test. 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
- files changed in the repo and helper state
- live instance files changed
- backup path created by the helper
- validation commands and results
## Mistake Recovery
If you installed from the wrong release or repo during the current task:
1. Restore affected live files from the helper backup created by that mistaken apply.
2. Remove mistaken downloaded assets and mistaken plan files from `.state`.
3. Correct the registry and lockfile to the user-provided release URL.
4. Rerun `check`, `plan`, and `apply` with `--state-dir .state`.
5. Keep or report only the backup relevant to the final correct apply unless the user asks for full audit history.
@@ -1,4 +1,4 @@
interface:
display_name: "Install Beat Saber Plugin"
short_description: "Update Beat Saber plugins via helper"
default_prompt: "Use $install-beatsaber-plugin to install or update a Beat Saber plugin from this release URL: <url>."
default_prompt: "Use $beatsaber-plugin-manager to install or update a Beat Saber plugin from this release URL: <url>."
@@ -1,174 +0,0 @@
---
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.
---
# 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.
## Hard Guardrail
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.
- Do not substitute a similar repo, fork, project, or package name.
- 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.
Accepted URL shapes include:
```text
https://github.com/<owner>/<repo>/releases
https://github.com/<owner>/<repo>/releases/tag/<tag>
https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
```
## Workflow
1. Confirm the workspace is the `plugin-helper` repo.
```bash
test -f pyproject.toml && test -d src/plugin_helper && test -d registry && test -d locks
```
2. Read the local helper behavior before changing files.
Inspect at least:
```bash
sed -n '1,220p' README.md
sed -n '1,260p' src/plugin_helper/cli.py
sed -n '1,260p' src/plugin_helper/planner.py
sed -n '1,220p' src/plugin_helper/models.py
sed -n '1,220p' registry/plugins.toml
sed -n '1,220p' locks/<instance>.lock.toml
```
3. Determine the instance.
Prefer the instance the user names. If omitted and the working context clearly points at one lockfile, use that instance. Otherwise run:
```bash
PYTHONPATH=src python -m plugin_helper instances
```
4. Snapshot first when requested.
If the user asks for a one-time or pre-helper snapshot, archive the instance's `Plugins/` directory before any install:
```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"
```
Report the archive path and hash.
5. Resolve the release from the user-provided URL only.
For GitHub URLs, 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
curl -sS https://api.github.com/repos/<owner>/<repo>/releases/tags/<tag>
```
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.
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>
```
Match the checksum against GitHub's `digest` when available. Inspect zip contents:
```bash
unzip -l .state/instances/<instance>/downloads/<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.
- `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.
Add or update exactly one `[[plugins]]` entry in `registry/plugins.toml` with:
```toml
[[plugins]]
id = "<stable-plugin-id>"
name = "<human name>"
repo = "<owner>/<repo>"
asset_patterns = ["<asset-name-or-pattern>"]
install_strategy = "<strategy>"
category = "<category-if-obvious>"
```
Add or update the matching `[[plugins]]` entry in `locks/<instance>.lock.toml` with:
```toml
[[plugins]]
id = "<stable-plugin-id>"
repo = "<owner>/<repo>"
tag = "<tag>"
asset = "<asset-name>"
sha256 = "<asset-sha256>"
```
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.
Always pass `--state-dir .state` so the helper uses the repo-local downloaded asset:
```bash
PYTHONPATH=src python -m plugin_helper --state-dir .state check --instance <instance>
PYTHONPATH=src python -m plugin_helper --state-dir .state plan --instance <instance> --plugin <plugin-id>
PYTHONPATH=src python -m plugin_helper --state-dir .state apply <generated-plan-path>
```
Before applying, read or summarize the generated plan enough to confirm it changes only the intended plugin files.
9. Verify the result.
Confirm the installed file hashes match the plan or archive members:
```bash
PYTHONPATH=src python -m plugin_helper --state-dir .state state --instance <instance>
PYTHONPATH=src python -m plugin_helper --state-dir .state check --instance <instance>
PYTHONPATH=src python -m unittest discover -s tests
```
Use `PYTHONPATH=src`; plain `python -m unittest` may fail in this source-layout repo.
10. 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
- validation commands and results
## Mistake Recovery
If you installed from the wrong release or repo during the current task:
1. Restore affected live files from the helper backup created by that mistaken apply.
2. Remove mistaken downloaded assets and mistaken plan files from `.state`.
3. Correct the registry and lockfile to the user-provided release URL.
4. Rerun `check`, `plan`, and `apply` with `--state-dir .state`.
5. Keep or report only the backup relevant to the final correct apply unless the user asks for full audit history.
+1
View File
@@ -1,4 +1,5 @@
/.state/
/.state-*/
/.pytest_cache/
/build/
/dist/
+55
View File
@@ -0,0 +1,55 @@
# 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 for planned installs unless the task explicitly
targets the user's live default state. Use `--state-dir .state` for the local
Linux install and `--state-dir .state-windows` for the mounted Windows install
when both roots contain the same instance name.
## 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, pass
`--instances-root /home/pleb/.local/share/BSManager/BSInstances` and normally
`--state-dir .state`.
- When targeting the mounted Windows BSManager install, pass
`--instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances` and
normally `--state-dir .state-windows`.
- 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, and
keep install/bootstrap state separate per target root.
## 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.
+140 -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,159 @@ 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
## Managing Multiple Installs
The helper is intended to manage both the local Linux BSManager install and the
mounted Windows install. Lockfiles and registry entries are shared by Beat Saber
version, but install state is target-specific. When the same instance name
exists under both roots, such as `1.44.1`, use an explicit `--instances-root`
and a separate state directory for each target.
Suggested repo-local convention:
```text
.state/ local Linux BSManager state
.state-windows/ mounted Windows BSManager state
```
Examples:
```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 \
--instances-root /home/pleb/.local/share/BSManager/BSInstances \
--state-dir .state \
installed --instance 1.44.1
PYTHONPATH=src python -m plugin_helper \
--instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances \
--state-dir .state-windows \
installed --instance 1.44.1
```
Do not reuse the same state directory for both targets when their instance names
match. The current state layout is keyed by instance name, so sharing one state
directory would mix bootstrap records, generated plans, backups, and installed
file records for different game trees.
## 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
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 or are intentionally targeting the Windows install with
`.state-windows`.
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.
For a second target-specific state directory, copy or re-download the same
locked assets under that state root before planning. For example:
```text
.state-windows/instances/<instance>/downloads/<plugin-id>/
```
## 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.
`restore-userdata` copies those backup trees back into a target instance. It
moves the current `UserData` and AppData trees aside first as
`<name>.pre-restore-<timestamp>` snapshots. On Linux BSManager installs,
AppData is restored into the BSManager SharedContent Proton prefix unless
`--appdata-path` is provided. If `backup-descriptor.json` is present, its
`instance` field must match `--instance`.
Example restore into the local Linux instance:
```sh
PYTHONPATH=src python -m plugin_helper \
--instances-root /home/pleb/.local/share/BSManager/BSInstances \
restore-userdata \
--instance 1.44.1
```
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).
+41 -5
View File
@@ -1,16 +1,23 @@
# plugin-helper Design
`plugin-helper` is a Python CLI for managing Beat Saber plugins in a mounted Windows BSManager install. It installs from individual GitHub releases, keeps per-game-version plugin selections pinned, records exact filesystem changes, and leaves compatibility judgment visible enough for an agent to help when upstream packaging is inconsistent.
`plugin-helper` is a Python CLI for managing Beat Saber plugins in BSManager
installs. It installs from pinned release artifacts, keeps per-game-version
plugin selections locked, records exact filesystem changes, and leaves
compatibility judgment visible enough for an agent to help when upstream
packaging is inconsistent.
The initial target is the Linux side of `incineroar`, after the Windows partition has been mounted manually. The current Beat Saber instances live under:
The current targets are the local Linux BSManager install and the mounted
Windows BSManager install:
```text
/home/pleb/Windows/Users/pleb/BSManager/BSInstances
/home/pleb/.local/share/BSManager/BSInstances
```
## Goals
- Manage plugins for one BSManager Beat Saber instance at a time, such as `1.40.8`.
- Manage plugins for one BSManager Beat Saber instance at a time, such as
`1.44.1`, while supporting the same instance name under multiple roots.
- Pull plugin releases directly from configured GitHub repositories.
- Determine candidate updates while respecting the pinned Beat Saber version.
- Support selective updates and explicit pins.
@@ -25,8 +32,10 @@ The initial target is the Linux side of `incineroar`, after the Windows partitio
- Replacing BSManager as a GUI or Beat Saber instance manager.
- Downloading or downgrading Beat Saber versions.
- Running `nixos-rebuild switch`.
- Mutating the Windows partition unless the user has mounted it and explicitly runs an apply command.
- Treating Nix as the plugin installer. Nix should package `plugin-helper`; the CLI should manage the mutable mounted game tree.
- Mutating the Windows partition unless the user has mounted it and explicitly
runs an apply command targeting that root.
- Treating Nix as the plugin installer. Nix should package `plugin-helper`; the
CLI should manage mutable game trees.
## Core Model
@@ -76,15 +85,42 @@ Runtime state should not need to live inside the repository. By default, keep mu
installed.json
plans/
downloads/
<plugin-id>/
backups/
```
For early development, a `--state-dir` option is useful so plans and manifests can be kept in the repo while the format settles.
When managing both local Linux and mounted Windows installs, install state must
be separated by target root as well as by instance name. The current state
layout is keyed by instance name, so two `1.44.1` installs should not share one
state directory. A practical repo-local convention is:
```text
.state/ local Linux BSManager state
.state-windows/ mounted Windows BSManager state
```
The registry and lockfile remain shared for a Beat Saber version. Downloads may
be copied or re-fetched into each target-specific state directory, but generated
plans, bootstrap records, backups, and `installed.json` belong to one target
game tree.
## Registry
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
+30 -7
View File
@@ -8,10 +8,20 @@ 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.
This works well while Beat Saber is still launched from a Windows install or a mounted Windows filesystem.
This works well while Beat Saber is launched from either the local Linux
BSManager install or a mounted Windows BSManager filesystem.
For the near term, the Python state model should treat target roots as distinct
installations even when they share an instance name. Lockfiles can stay keyed by
Beat Saber version, but bootstrap state, generated plans, backups, and
`installed.json` need to stay target-specific.
## Future: Nix-Orchestrated Plugin Sets
@@ -64,12 +74,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 20 seconds wall time for launch,
menu/UI initialization, 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 repeatedly 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 20
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,299 @@
# 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 | Upstream | Status | Source/version | Verification notes |
| --- | --- | --- | --- | --- |
| BSIPA | [github](https://github.com/nike4613/BeatSaber-IPA-Reloaded) | <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 | Upstream | Status | Source/version | Verification notes |
| --- | --- | --- | --- | --- |
| SongCore | [beatmods zip](https://beatmods.com/cdn/mod/0af9c0a03074c17ca15c1b667a0e30c8.zip) | <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 | [github](https://github.com/monkeymanboy/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 | [github](https://github.com/Auros/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 | Upstream | Status | Source/version | Verification notes |
| --- | --- | --- | --- | --- |
| CustomJSONData | [github](https://github.com/Aeroluna/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 | [github](https://github.com/Aeroluna/Heck) | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `Aeroluna/Heck` tag `v2026-05-02`, asset `Heck-1.8.3+1.42.1-bs1.42.1-3ebc6a2.zip`; no BeatMods verified 1.44.1 entry found on 2026-06-28 | Latest upstream Heck asset targets `bs1.42.1`; IPA loaded Heck 1.8.3+1.42.1 and installed Heck app/menu installers during smoketest. |
| Chroma | [github](https://github.com/Aeroluna/Heck) | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `Aeroluna/Heck` tag `v2026-02-23`, asset `Chroma-2.9.22+1.42.1-bs1.42.1-b38d924.zip`; no BeatMods verified 1.44.1 entry found on 2026-06-28 | Required `LookupID` 1.0.1. Latest upstream Chroma asset targets `bs1.42.1`; IPA loaded Chroma 2.9.22+1.42.1 and installed Chroma app/menu installers during smoketest. |
| NoodleExtensions | [github](https://github.com/Aeroluna/Heck) | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `Aeroluna/Heck` tag `v2026-05-09`, asset `NoodleExtensions-1.7.21+1.42.1-bs1.42.1-3bbcaf6.zip`; no BeatMods verified 1.44.1 entry found on 2026-06-28 | Latest upstream Noodle Extensions asset targets `bs1.42.1`; IPA loaded NoodleExtensions 1.7.21+1.42.1 and installed Noodle app installer during smoketest. |
| Vivify | [github](https://github.com/Aeroluna/Vivify) | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `Aeroluna/Vivify` tag `v1.1.0`, asset `Vivify-1.1.0+1.42.1-bs1.42.1-f83aa3c.zip`; no BeatMods verified 1.44.1 entry found on 2026-06-28 | Required `CameraUtils` 1.0.8 and `AssetBundleLoadingTools` 1.1.13. Latest upstream Vivify asset targets `bs1.42.1`; IPA loaded Vivify 1.1.0+1.42.1, installed Vivify app/menu installers, and created camera controllers during smoketest. |
### Batch 3: Downloaders and Playlists
Purpose: restore in-game song discovery and playlist management.
| Plugin | Upstream | Status | Source/version | Verification notes |
| --- | --- | --- | --- | --- |
| BeatSaverDownloader | [beatmods zip](https://beatmods.com/cdn/mod/a740c6e68a9b5d1dfda3cc8e81f7cf06.zip) | <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 | [github](https://github.com/rithik-b/PlaylistManager) | <span style="color:#3fb950; font-weight:600">verified</span> | Local PR82 build from `.state/build/playlistmanager-pr82-skilltest`, artifact `PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip`; replaces failed BeatMods 1.7.3 compatibility trial | IPA loaded PlaylistManager 1.7.4, installed `PlaylistManagerAppInstaller`, and reached `MainSystemInit` during smoketest. The old `IPlatformUserModel` / `PlatformUserModel` failure did not recur. |
| BeatSaverUpdater | [github](https://github.com/ibillingsley/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 | [beatmods zip](https://beatmods.com/cdn/mod/bc002ed1a43e2c6d3a10d0750e5d94b4.zip) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | BeatMods 2.4.6, version id 2159, zipHash `bc002ed1a43e2c6d3a10d0750e5d94b4`; most recent blessed entry found was for Beat Saber 1.40.8, with no BeatMods verified entry for 1.44.1, 1.44.0, 1.43.0, 1.42.0, or 1.41.1 | IPA discovered and loaded BeatSaverVoting 2.4.6, but BS Utils caught a menu event failure from `BeatSaverVoting` caused by `TypeLoadException` resolving `IPlatformUserModel` from `PlatformUserModel`. Removed from the live instance after the failed smoketest. |
| BeatSaberPlaylistsLib | [beatmods zip](https://beatmods.com/cdn/mod/a3418b75ed7294a3856f3eca12bbd672.zip) | <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 | [beatmods zip](https://beatmods.com/cdn/mod/be37e13e93d9ac7da4efbdc3f514fa8f.zip) | <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 | [beatmods zip](https://beatmods.com/cdn/mod/8713168c598577ee7c73fa3cf0e26f5c.zip) | <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 | [beatmods zip](https://beatmods.com/cdn/mod/918d13ac2821a3a17b2819f8861453e9.zip) | <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 | [beatmods zip](https://beatmods.com/cdn/mod/5df74ad1c6b120fecdc615dd55f15b88.zip) | <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 | [beatmods zip](https://beatmods.com/cdn/mod/b642fec88b0f84a0643ebd401d08da35.zip) | <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 | [beatmods zip](https://beatmods.com/cdn/mod/a4e9e26f61967e56168e08eecb01ab88.zip) | <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 | [beatmods zip](https://beatmods.com/cdn/mod/e19f6fd395d54de7bfcbbbe3084dea28.zip) | <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 | Upstream | Status | Source/version | Verification notes |
| --- | --- | --- | --- | --- |
| ScoreSaber | [github](https://github.com/ScoreSaber/pc-mod) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `ScoreSaber/pc-mod` tag `v3.3.27`, asset `ScoreSaber-v3.3.27-bs1.42.0-to-1.44.0-9b4cfcf.zip`; GitHub release digest matched downloaded asset | IPA loaded ScoreSaber 3.3.27 and synchronized its clock. Warning: FPFC/no-VR smoke logged `openxr_loader not found` while trying to get HMD info. |
| BeatLeader + LeaderboardCore | [github](https://github.com/BeatLeader/beatleader-mod) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `BeatLeader/beatleader-mod` tag `v0.10.0`, asset `BeatLeader-0.10.0-bs1.42+.zip`; GitHub release digest matched downloaded asset; archive bundles `Plugins/LeaderboardCore.dll`, superseding the standalone BeatMods CDN trial | IPA loaded BeatLeader 0.10.0 and bundled LeaderboardCore 1.7.0, made BeatLeader API requests, and patched 13 ScoreSaber installers. Warnings: optional interop plugins missing; FPFC/no-VR smoke logged OpenXR session not running; bundled LeaderboardCore logged a Harmony patch error for `PanelView_SetIsLoaded`. |
| AccSaber | [github](https://github.com/not-dexter/accsaber-reloaded-plugin) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | GitHub `not-dexter/accsaber-reloaded-plugin` tag `v1.1.3`, asset `1.40.8.zip`; GitHub release digest matched downloaded asset; latest release has no 1.44.x asset | IPA loaded AccSaber 1.1.3, but the smoke log hit `TypeLoadException: Invalid type AccSaber.Managers.AccSaberStore` for `AccSaberMissionScreen`. Removed from the live instance after the failed trial. |
| SongRankedBadge | [github](https://github.com/qe201020335/SongRankedBadge) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `qe201020335/SongRankedBadge` tag `v1.0.6`, asset `SongRankedBadge-1.0.6-bs1.40.0-88ee233.zip`; BeatMods version id 2267, zipHash `c6944b8a4b00b0c0bb1d44f273b3bb18` | IPA loaded SongRankedBadge 1.0.6, resolved SongDetailsCache, loaded song details, and initialized. Warning: manifest targets Beat Saber 1.40.0. |
### Batch 5: Practice and Gameplay Tweaks
Purpose: add small gameplay helpers two or three at a time.
| Plugin | Upstream | Status | Source/version | Verification notes |
| --- | --- | --- | --- | --- |
| IntroSkip | [github](https://github.com/Loloppe/Intro-Skip), [beatmods zip](https://beatmods.com/cdn/mod/7d98ae6049251eb4e3226a5c8ac675b3.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 4.0.8, version id 2570, zipHash `7d98ae6049251eb4e3226a5c8ac675b3`; replaces failed GitHub `Loloppe/Intro-Skip` tag `4.0.5`, asset `IntroSkip-v4.0.5+bs.1.37.2.zip` | IPA loaded Intro Skip 4.0.8 and the game reached `MainSystemInit`; the old `IntroSkip.UI.SettingsHost` `TypeLoadException` did not recur. Warning: manifest targets Beat Saber 1.40.11. |
| FailButton | [github](https://github.com/qe201020335/FailButton) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `qe201020335/FailButton` tag `v0.0.4`, asset `FailButton-0.0.4-bs1.39.0-b6415fb.zip` | IPA loaded FailButton 0.0.4 and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.39.0. |
| EasyOffset | [github](https://github.com/Reezonate/EasyOffset) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `Reezonate/EasyOffset` tag `v2.1.16`, asset `EasyOffset.dll`; GitHub release digest matched downloaded asset | IPA loaded EasyOffset 2.1.16, fetched remote config successfully, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.0. |
| GottaGoFast | [github](https://github.com/kinsi55/CS_BeatSaber_GottaGoFast) | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `kinsi55/CS_BeatSaber_GottaGoFast` tag `v0.2.5`, asset `GottaGoFast.dll`; GitHub release digest matched downloaded asset | IPA loaded Gotta Go Fast 0.2.5 and the game reached `MainSystemInit`. |
| HitsoundTweaks | [github](https://github.com/GalaxyMaster2/HitsoundTweaks) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | GitHub `GalaxyMaster2/HitsoundTweaks` tag `v1.1.9`, asset `HitsoundTweaks-1.1.9-bs1.40.3-4ad8461.zip`; GitHub release digest matched downloaded asset | IPA loaded HitsoundTweaks 1.1.9, but SiraUtil failed to apply the `AudioTimeSyncController_dspTimeOffset_Patch` affinity patch with `InvalidProgramException`. Removed from the live instance after the failed smoke. BeatMods verified search on 2026-06-29 found no `HitsoundTweaks` entry for Beat Saber 1.42.0, 1.42.1, 1.43.0, 1.44.0, or 1.44.1. |
| KeepMyOverridesPls | [github](https://github.com/qqrz997/KeepMyOverridesPls) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `qqrz997/KeepMyOverridesPls` tag `v1.1.3-b`, asset `KeepMyOverridesPls-1.1.3-bs1.40.6-487d417.zip`; GitHub release digest matched downloaded asset | IPA loaded KeepMyOverridesPls 1.1.3, installed its app installer, wrote config, and the game reached `MainSystemInit`. Warnings: manifest targets Beat Saber 1.40.6 and plugin has no start/exit methods. |
| SoundReplacer | [github](https://github.com/Meivyn/SoundReplacer), [beatmods zip](https://beatmods.com/cdn/mod/7d7a869996e10249d1f85f95e060319b.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 2.0.1, version id 2213, zipHash `7d7a869996e10249d1f85f95e060319b`; GitHub releases API returned no releases on 2026-06-29 | IPA loaded SoundReplacer 2.0.1, installed its app and menu installers, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.29.4. |
| KeyRemapper | [github](https://github.com/lyyQwQ/KeyRemapper) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `lyyQwQ/KeyRemapper` tag `0.3.0`, asset `KeyRemapper-0.3.0-bs1.39.1-8e4c11a.zip`; GitHub release digest matched downloaded asset | IPA loaded KeyRemapper 0.3.0, initialized config, installed menu bindings, registered the Key Remapper button, and the game reached `MainSystemInit`. Warnings: manifest targets Beat Saber 1.39.1 and FPFC smoke logged dummy input manager because runtime was null. |
| SquatToBegin | [github](https://github.com/kinsi55/BeatSaber_SquatToBegin) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `kinsi55/BeatSaber_SquatToBegin` tag `v0.0.7`, asset `SquatToBegin.dll`; GitHub release digest matched downloaded asset | IPA loaded SquatToBegin 0.0.7 and the game reached `MainSystemInit`. Warnings: manifest reports Beat Saber 1.20.0 despite the GitHub release being labeled for 1.39.1+, and plugin has no start/exit methods. Verify squat gate behavior outside FPFC when practical. |
| JDFixer | [github](https://github.com/zeph-yr/JDFixer), [pr](https://github.com/zeph-yr/JDFixer/pull/26) | <span style="color:#d29922; font-weight:600">installed; smoke blocked</span> | Local build from PR 26 commit `3fce6ce465911bdd5e8e00411bc4672c54a317f7`, asset `JDFixer.dll`; SHA-256 `16b7dad9906d838dab40ce48a9b304be4847f18e700ddd31f2293d1065f4529d` | IPA loaded JDFixer 7.4.0 and the prior `OnEnable` Harmony failure did not recur; JDFixer logged config/donate activity. The smoke did not reach the main menu because Steam platform initialization failed (`SteamAPI Init failed`, Steam likely not running). Replaces failed GitHub `zeph-yr/JDFixer` tag `v.7.4.0`, asset `JDFixer.dll`, SHA-256 `a83ae3f68921a9698616ecd89d08b7397a550c2464a7871b6c65506ce0c7d360`; that release loaded JDFixer 7.4.0, but `OnEnable` failed with a Harmony patching exception because `StandardLevelScenesTransitionSetupDataSOPatch::TargetMethod()` returned null. |
### Batch 6: UI and Song Browser
Purpose: restore song-list, menu, and visualization conveniences.
| Plugin | Upstream | Status | Source/version | Verification notes |
| --- | --- | --- | --- | --- |
| BetterSongList | [github](https://github.com/kinsi55/BeatSaber_BetterSongList) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `kinsi55/BeatSaber_BetterSongList` tag `v0.4.3`, asset `BetterSongList.dll`; GitHub release digest matched downloaded asset | IPA loaded BetterSongList 0.4.3 and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.0. |
| HitScoreVisualizer | [github](https://github.com/ErisApps/HitScoreVisualizer) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `ErisApps/HitScoreVisualizer` tag `3.7.3`, asset `HitScoreVisualizer-3.7.3-bs1.42.0-a565cbb.zip`; GitHub release digest matched downloaded asset | IPA loaded HitScoreVisualizer 3.7.3, installed app/menu installers, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.0. |
| DiTails | [github](https://github.com/Auros/DiTails) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | GitHub `Auros/DiTails` tag `1.1.3`, asset `DiTails-v1.1.3-g1.42.0-271d394.zip`; GitHub release digest matched downloaded asset; BeatMods also verifies DiTails 1.1.3 for 1.44.1 as version id 2609, zipHash `437904f6db78a2ee928738d7d254a93f` | IPA loaded DiTails 1.1.3, but menu initialization failed in `DiTails.Managers.DetailContextManager.Initialize()` with `NullReferenceException`. Removed from the live instance after the failed smoke. |
| HideTheLogo | [github](https://github.com/TheBlackParrot/HideTheLogo) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `TheBlackParrot/HideTheLogo` tag `1.0.3`, asset `HideTheLogo-1.0.3-bs1.40.3-c968d91.zip` | IPA loaded HideTheLogo 1.0.3, logged `yeet`, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.40.3. |
| SongChartVisualizer | [github](https://github.com/NuggoDEV/SongChartVisualizer), [beatmods zip](https://beatmods.com/cdn/mod/5d3fc025fe098277667fc0846e1b8fe3.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 1.1.11, version id 2249, zipHash `5d3fc025fe098277667fc0846e1b8fe3`; GitHub releases API returned no releases on 2026-06-29 | IPA loaded SongChartVisualizer 1.1.11, installed app/menu installers, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.39.1. |
| Setlist | | <span style="color:#f85149; font-weight:600">skipped after local-build trial</span> | Local build from `/home/pleb/ops/beatsaber/setlist` commit `14d21ad` with working tree modifications; first SHA-256 `01ecba3cfa697488faddf6eb8bfcc1aedff5d95f4b5a9f673f70b0ff150f5ab9`, rebuilt SHA-256 `57f07f5d99505ee35d45b3914484434fed98113c84cb94e74c6b362abd2216a1` after manifest dependency fix | First smoke ignored Setlist because `BeatLeader@^0.9.0` did not accept installed BeatLeader 0.10.0; after widening to `>=0.9.0`, IPA loaded Setlist 0.1.0 but only logged `No playlists loaded` and did not produce the expected `platformUserId`/playlist ownership lines. Removed from the live instance per skip instruction. |
### Batch 7: Cosmetic, Camera, and Lighting
Purpose: add visual and stream-facing mods after functional mods are stable.
| Plugin | Upstream | Status | Source/version | Verification notes |
| --- | --- | --- | --- | --- |
| AdBlocker | [github](https://github.com/JonnyVR1/AdBlocker), [beatmods zip](https://beatmods.com/cdn/mod/cd397e93b1a03f163534483462edf768.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 1.0.5, version id 1872, zipHash `cd397e93b1a03f163534483462edf768`; GitHub releases API returned no releases on 2026-06-29 | IPA loaded AdBlocker 1.0.5, logged `AdBlocker initialized`, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.34.2. |
| HighlightBombs | [github](https://github.com/Meivyn/HighlightBombs), [beatmods zip](https://beatmods.com/cdn/mod/4bedaa80ce5dda8414fea7d914fb94ad.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 1.0.3, version id 2066, zipHash `4bedaa80ce5dda8414fea7d914fb94ad`; GitHub latest release v1.0.1 is older than the BeatMods-verified package | IPA loaded HighlightBombs 1.0.3, installed its app installer, loaded QuickOutline material, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.32.0. In-song bomb visuals not exercised in FPFC smoke. |
| PitchBlack | [github](https://github.com/Loloppe/BeatSaber_PitchBlack), [beatmods zip](https://beatmods.com/cdn/mod/65656bf33b2a0b356c381132387ea7ea.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `Loloppe/BeatSaber_PitchBlack` tag `0.03`, asset `PitchBlack-v0.0.3-bs1.39.1.zip`; BeatMods version id 2233, zipHash `65656bf33b2a0b356c381132387ea7ea`; GitHub asset is byte-identical to the BeatMods CDN zip | IPA loaded PitchBlack 0.0.3, generated config, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.39.1. In-song lighting behavior not exercised in FPFC smoke. |
| Dimmer | | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual install candidate. |
| ReeCamera | [github](https://github.com/Reezonate/ReeCamera) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `Reezonate/ReeCamera` tag `v0.0.5`, asset `ReeCamera.1.42.0.zip`; GitHub release digest matched downloaded asset | IPA loaded ReeCamera 0.0.5, logged Spout load success, installed app/menu installers, and the game reached `MainSystemInit`. Warnings: manifest targets Beat Saber 1.42.0; first launch logged missing `UserData/ReeCamera.json` until the mod creates it on exit per upstream docs. Archive also replaced bundled `Plugins/CameraUtils.dll`. Camera presets not exercised in FPFC smoke. |
### Batch 8: Paid or Closed-Source Plugins
Purpose: restore private plugin set only after public/dependency-heavy mods are
known good.
| Plugin | Upstream | Status | Source/version | Verification notes |
| --- | --- | --- | --- | --- |
| ReeSabers | | <span style="color:#8b949e; font-weight:600">todo</span> | paid/private | Verify saber visuals in VR when practical. |
| 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 | Upstream | Status | Required by | Source/version | Verification notes |
| --- | --- | --- | --- | --- | --- |
| AssetBundleLoadingTools | [github](https://github.com/nicoco007/AssetBundleLoadingTools) | <span style="color:#8b949e; font-weight:600">todo</span> | Vivify | TBD | Usually `Libs/`. |
| BS Utils | [beatmods zip](https://beatmods.com/cdn/mod/918d13ac2821a3a17b2819f8861453e9.zip) | <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 | [github](https://github.com/Reezonate/CameraUtils) | <span style="color:#8b949e; font-weight:600">todo</span> | Vivify | TBD | Verify no missing assembly errors. |
| ImageSharp | [beatmods zip](https://beatmods.com/cdn/mod/b642fec88b0f84a0643ebd401d08da35.zip) | <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 | [beatmods zip](https://beatmods.com/cdn/mod/5df74ad1c6b120fecdc615dd55f15b88.zip) | <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 | [github](https://github.com/Aeroluna/Heck) | <span style="color:#8b949e; font-weight:600">todo</span> | Chroma | TBD | Verify no missing assembly errors. |
| OpenVR API | [github](https://github.com/nicoco007/BeatSaber-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 | [beatmods zip](https://beatmods.com/cdn/mod/1f55ae4b80b747b5f03fa18337ead864.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | SongDetailsCache | BeatMods 3.0.102, version id 958, zipHash `1f55ae4b80b747b5f03fa18337ead864` | IPA loaded protobuf-net 3.0.102. Warning: manifest targets Beat Saber 1.13.2. |
| SongDetailsCache | [github](https://github.com/kinsi55/BeatSaber_SongDetails) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BetterSongList, SongRankedBadge | GitHub `kinsi55/BeatSaber_SongDetails` tag `v1.4.0`, asset `SongDetailsCache.BS.Lib.zip`; BeatMods version id 2226, zipHash `e1167b64cd3eff7e3651ec2dbbe50d81` | IPA loaded SongDetailsCache 1.4.0 and SongRankedBadge used it to load song details. Warning: manifest targets Beat Saber 1.13.2. |
| System.IO.Compression | [beatmods zip](https://beatmods.com/cdn/mod/a4e9e26f61967e56168e08eecb01ab88.zip) | <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 | [beatmods zip](https://beatmods.com/cdn/mod/e19f6fd395d54de7bfcbbbe3084dea28.zip) | <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 | Upstream | Reason omitted | Evidence/log note | Follow-up |
| --- | --- | --- | --- | --- |
| Heck | [github](https://github.com/Aeroluna/Heck) | No BeatMods verified 1.44.1 entry found on 2026-06-28. | Not installed. | Revisit only with a compatible source. |
| Chroma | [github](https://github.com/Aeroluna/Heck) | No BeatMods verified 1.44.1 entry found on 2026-06-28. | Not installed. | Revisit after Heck is available. |
| NoodleExtensions | [github](https://github.com/Aeroluna/Heck) | No BeatMods verified 1.44.1 entry found on 2026-06-28. | Not installed. | Revisit after Heck is available. |
| Vivify | [github](https://github.com/Aeroluna/Vivify) | No BeatMods verified 1.44.1 entry found on 2026-06-28. | Not installed. | Revisit after Heck is available. |
| PlaylistManager | [github](https://github.com/rithik-b/PlaylistManager) | No BeatMods verified 1.44.1 entry found on 2026-06-28; latest blessed BeatMods entry found was 1.7.3 for Beat Saber 1.40.8. | Installed for a compatibility trial, then removed after smoketest failure. | Fails on 1.44.1 menu setup with `IPlatformUserModel` / `PlatformUserModel` type resolution; needs a newer compatible build. |
| BeatSaverVoting | [beatmods zip](https://beatmods.com/cdn/mod/bc002ed1a43e2c6d3a10d0750e5d94b4.zip) | No BeatMods verified 1.44.1 entry found on 2026-06-28; latest blessed BeatMods entry found was 2.4.6 for Beat Saber 1.40.8. | Installed for a compatibility trial, then removed after smoketest failure. | Fails on 1.44.1 menu event handling with `IPlatformUserModel` / `PlatformUserModel` type resolution; needs a newer compatible build. |
## 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 | 2026-06-29 | <span style="color:#d29922; font-weight:600">verified with warning</span> | Installed and smoke-tested ScoreSaber, BeatLeader, BeatLeader-bundled LeaderboardCore, SongRankedBadge, SongDetailsCache, and protobuf-net. AccSaber Reloaded was trialed from the user-provided GitHub release page and removed after a compatibility failure. | Warnings: ScoreSaber and BeatLeader assets target 1.42.x/1.44.0 ranges rather than explicit 1.44.1; BeatLeader-bundled LeaderboardCore logged a Harmony patch error; AccSaber 1.40.8 asset failed with a `TypeLoadException`; existing BeatSaverDownloader BetterSongList probe still logs `CRITICAL` library-loader lines. |
| 5 | | <span style="color:#8b949e; font-weight:600">todo</span> | | |
| 6 | | <span style="color:#8b949e; font-weight:600">todo</span> | | |
| 7 | 2026-06-29 | <span style="color:#d29922; font-weight:600">verified with warning</span> | Installed and smoke-tested AdBlocker 1.0.5, HighlightBombs 1.0.3, PitchBlack 0.0.3, and ReeCamera 0.0.5. All four loaded and the game reached `MainSystemInit`. HighlightBombs installed its app installer and loaded QuickOutline material. ReeCamera logged Spout load success and installed app/menu installers. | Warnings: all four manifests target older Beat Saber versions (1.34.2, 1.32.0, 1.39.1, 1.42.0). AdBlocker and PitchBlack used BeatMods CDN or byte-identical GitHub assets because JonnyVR1/AdBlocker exposes no GitHub releases and HighlightBombs BeatMods 1.0.3 is newer than GitHub v1.0.1. ReeCamera first launch logged missing `UserData/ReeCamera.json` until mod creates it on exit; archive replaced bundled `CameraUtils.dll`. In-song bomb/lighting/camera visuals were not exercised in FPFC smoke. Pre-existing LeaderboardCore and PlaylistManager errors still appear in the log. |
| 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
+404
View File
@@ -0,0 +1,404 @@
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 = "heck"
repo = "Aeroluna/Heck"
tag = "v2026-05-02"
asset = "Heck-1.8.3+1.42.1-bs1.42.1-3ebc6a2.zip"
sha256 = "d6c6a2b7e54285c8e7ee0515546f8cec274033afaa11324164803d003d9afab4"
install_strategy = "bsipa-zip"
reason = "No BeatMods verified 1.44.1 entry was returned on 2026-06-28. Use latest upstream GitHub Heck asset targeting the highest available Beat Saber version, bs1.42.1; GitHub release digest matched the downloaded asset."
[[plugins]]
id = "chroma"
repo = "Aeroluna/Heck"
tag = "v2026-02-23"
asset = "Chroma-2.9.22+1.42.1-bs1.42.1-b38d924.zip"
sha256 = "5438d039336ec91573818b7dbd8d5fce30454d16a66f3e70d6eedf5bff6c32bf"
install_strategy = "bsipa-zip"
reason = "No BeatMods verified 1.44.1 entry was returned on 2026-06-28. Use latest upstream GitHub Chroma asset targeting the highest available Beat Saber version, bs1.42.1; GitHub release digest matched the downloaded asset."
[[plugins]]
id = "lookupid"
repo = "Aeroluna/Heck"
tag = "chroma-v2.5.10"
asset = "LookupID-1.0.1.zip"
sha256 = "5fc838c9bf3693a8e3728056d95c07c5b0e13f944c1f9f49607f701b58e3d05d"
install_strategy = "bsipa-zip"
reason = "Required by Chroma. BeatMods verified LookupID 1.0.1 for Beat Saber 1.44.1 as version id 1527, zipHash 5f5655b91193602c0311b8b6d1b4c71c. GitHub exposes the same version as an upstream release asset."
[[plugins]]
id = "noodleextensions"
repo = "Aeroluna/Heck"
tag = "v2026-05-09"
asset = "NoodleExtensions-1.7.21+1.42.1-bs1.42.1-3bbcaf6.zip"
sha256 = "116d5a56c52faf652cecee2dd0b9a02c1798d893fb5adef4145a1fa555ef727f"
install_strategy = "bsipa-zip"
reason = "No BeatMods verified 1.44.1 entry was returned on 2026-06-28. Use latest upstream GitHub Noodle Extensions asset targeting the highest available Beat Saber version, bs1.42.1; GitHub release digest matched the downloaded asset."
[[plugins]]
id = "camerautils"
repo = "Reezonate/CameraUtils"
tag = "beatmods-1.0.8"
asset = "CameraUtils-1.0.8.zip"
sha256 = "7066ec311dc599e1d654d70557abc6c0809b72b5f4d695c8cfb85bb0bf2e2366"
install_strategy = "bsipa-zip"
reason = "Required by Vivify. BeatMods verified CameraUtils 1.0.8 for Beat Saber 1.44.1 as version id 2576, zipHash 38507e3a9ff8486c75b002cc47226f43. Upstream GitHub latest release is 1.0.7, so use the newer BeatMods CDN artifact."
[[plugins]]
id = "assetbundleloadingtools"
repo = "nicoco007/AssetBundleLoadingTools"
tag = "v1.1.13"
asset = "AssetBundleLoadingTools-v1.1.13+bs.1.41.1.zip"
sha256 = "356b1aa4722ecca6f13199ad378429ece9873c365d9cf013e095fd5c0757a127"
install_strategy = "root-zip"
reason = "Required by Vivify. BeatMods verified AssetBundleLoadingTools 1.1.13 for Beat Saber 1.44.1 as version id 2590, zipHash a381a964cb69d26be8348edf04fabbc7. GitHub asset is the same version and its release digest matched the downloaded asset."
[[plugins]]
id = "vivify"
repo = "Aeroluna/Vivify"
tag = "v1.1.0"
asset = "Vivify-1.1.0+1.42.1-bs1.42.1-f83aa3c.zip"
sha256 = "0e8799a7243496925aa527bc4ca7d3bcab770f4e828bbceeed066539f90a0902"
install_strategy = "bsipa-zip"
reason = "No BeatMods verified 1.44.1 entry was returned on 2026-06-28. Use latest upstream GitHub Vivify asset targeting the highest available Beat Saber version, bs1.42.1; GitHub release digest matched the downloaded asset."
[[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 = "scoresaber"
repo = "ScoreSaber/pc-mod"
tag = "v3.3.27"
asset = "ScoreSaber-v3.3.27-bs1.42.0-to-1.44.0-9b4cfcf.zip"
sha256 = "8ad509ab38353dfb4d92d127ba4e40917552f700580a7384509cafa70e62c096"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub releases URL https://github.com/ScoreSaber/pc-mod/releases. Latest non-draft, non-prerelease release v3.3.27 includes this highest compatible asset, labeled for Beat Saber 1.42.0 through 1.44.0; GitHub release digest matched the downloaded asset."
[[plugins]]
id = "protobuf-net"
repo = "protobuf-net/protobuf-net"
tag = "beatmods-3.0.102"
asset = "protobuf-net-3.0.102.zip"
sha256 = "8bf4a361038172eab2c0c2e6737773d6c9a47fc6504a97304a35f697d5166414"
install_strategy = "bsipa-zip"
reason = "Required by SongDetailsCache. BeatMods verified protobuf-net 3.0.102 for Beat Saber 1.44.1 as version id 958, zipHash 1f55ae4b80b747b5f03fa18337ead864. Use BeatMods CDN as a framework/library dependency payload."
[[plugins]]
id = "songdetailscache"
repo = "kinsi55/BeatSaber_SongDetails"
tag = "v1.4.0"
asset = "SongDetailsCache.BS.Lib.zip"
sha256 = "6ccc816dd40752b46d5e7e1cfb41e5af9d915d1a032be51ce1e52fb154daa28c"
install_strategy = "bsipa-zip"
reason = "Required by SongRankedBadge. BeatMods verified SongDetailsCache 1.4.0 for Beat Saber 1.44.1 as version id 2226, zipHash e1167b64cd3eff7e3651ec2dbbe50d81. GitHub asset is the same version selected from kinsi55/BeatSaber_SongDetails v1.4.0."
[[plugins]]
id = "songrankedbadge"
repo = "qe201020335/SongRankedBadge"
tag = "v1.0.6"
asset = "SongRankedBadge-1.0.6-bs1.40.0-88ee233.zip"
sha256 = "6028f2392dd5e228f477837a7e32d95f0ca8af4f1b697b17c842ea62c050edff"
install_strategy = "bsipa-zip"
reason = "BeatMods verified SongRankedBadge 1.0.6 for Beat Saber 1.44.1 as version id 2267, zipHash c6944b8a4b00b0c0bb1d44f273b3bb18. GitHub release asset v1.0.6 was used."
[[plugins]]
id = "beatleader"
repo = "BeatLeader/beatleader-mod"
tag = "v0.10.0"
asset = "BeatLeader-0.10.0-bs1.42+.zip"
sha256 = "0b7c2adcf4db9806cdc765164788a9394bca2ee12179fad1b8ed255da1b7104e"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub releases URL https://github.com/BeatLeader/beatleader-mod/releases. Latest non-draft, non-prerelease release v0.10.0 includes this highest compatible asset, labeled for Beat Saber 1.42+; GitHub release digest matched the downloaded asset. The archive bundles Plugins/LeaderboardCore.dll, so the standalone LeaderboardCore lock entry is intentionally omitted."
[[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 = "playlistmanager"
repo = "rithik-b/PlaylistManager"
tag = "pr-82-localbuild-da1ad17"
asset = "PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip"
sha256 = "57b449e614db1d5214cd3a88000d52f5a989c8521390a9f4819b1c62f20f16fa"
install_strategy = "bsipa-zip"
reason = "Local build from .state/build/playlistmanager-pr82-skilltest, artifact PlaylistManager/bin/Release/net48/zip/PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip. Use this PR82 build instead of the failed BeatMods 1.7.3 compatibility trial for Beat Saber 1.44.1."
[[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."
[[plugins]]
id = "failbutton"
repo = "qe201020335/FailButton"
tag = "v0.0.4"
asset = "FailButton-0.0.4-bs1.39.0-b6415fb.zip"
sha256 = "bb163170f400191592af1689e7b7557ec82e45e36de36e6eaaae16b4273e595b"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub repository URL https://github.com/qe201020335/FailButton. Latest non-draft, non-prerelease release v0.0.4 exposes this asset; no newer 1.44.x-specific asset was available."
[[plugins]]
id = "easyoffset"
repo = "Reezonate/EasyOffset"
tag = "v2.1.16"
asset = "EasyOffset.dll"
sha256 = "6b9c63e5a4ae0ebc103fc11f442917b8fae12d62369faed1aca7470899a2bbbf"
install_strategy = "dll-to-plugins"
reason = "User-provided GitHub repository URL https://github.com/Reezonate/EasyOffset. Latest non-draft, non-prerelease release v2.1.16 exposes this direct DLL asset; GitHub release digest matched the downloaded asset."
[[plugins]]
id = "gottagofast"
repo = "kinsi55/CS_BeatSaber_GottaGoFast"
tag = "v0.2.5"
asset = "GottaGoFast.dll"
sha256 = "c9cd0e29dd7540027cdd3b9138335d81f93d69daeb8460d2fdfef31199e0e44e"
install_strategy = "dll-to-plugins"
reason = "User-provided GitHub repository URL https://github.com/kinsi55/CS_BeatSaber_GottaGoFast. Latest non-draft, non-prerelease release v0.2.5 is labeled for Beat Saber 1.42.0+ and exposes this direct DLL asset; GitHub release digest matched the downloaded asset."
[[plugins]]
id = "keepmyoverridespls"
repo = "qqrz997/KeepMyOverridesPls"
tag = "v1.1.3-b"
asset = "KeepMyOverridesPls-1.1.3-bs1.40.6-487d417.zip"
sha256 = "4c2da7349778eba98eb02ba518a1a056a784b719b974055c877aa6ce3e735fbc"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub repository URL https://github.com/qqrz997/KeepMyOverridesPls. Latest non-draft, non-prerelease release v1.1.3-b exposes this asset; GitHub release digest matched the downloaded asset."
[[plugins]]
id = "keyremapper"
repo = "lyyQwQ/KeyRemapper"
tag = "0.3.0"
asset = "KeyRemapper-0.3.0-bs1.39.1-8e4c11a.zip"
sha256 = "962e3f6e18bebdf101e6575fa7b8b7e0d92179bab4f0825d122078f1842f7380"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub releases URL https://github.com/lyyQwQ/KeyRemapper/releases. Latest non-draft, non-prerelease release 0.3.0 exposes this asset; GitHub release digest matched the downloaded asset. Release notes label compatibility as Beat Saber 1.39.1 with BSIPA/BSML/SiraUtil dependencies."
[[plugins]]
id = "squattobegin"
repo = "kinsi55/BeatSaber_SquatToBegin"
tag = "v0.0.7"
asset = "SquatToBegin.dll"
sha256 = "8426a64f6a3224b8cd79b9ee86347727a5a43c40a0fda116bf9c613f145fc18e"
install_strategy = "dll-to-plugins"
reason = "User-provided GitHub releases URL https://github.com/kinsi55/BeatSaber_SquatToBegin/releases. Latest non-draft, non-prerelease release v0.0.7 is labeled for Beat Saber 1.39.1+ and exposes this direct DLL asset; GitHub release digest matched the downloaded asset."
[[plugins]]
id = "introskip"
repo = "Loloppe/Intro-Skip"
tag = "beatmods-4.0.8"
asset = "IntroSkip-4.0.8.zip"
sha256 = "319bce38f69661a3b0997071b85d57e5b4295b9699ac5432877b0fed72aa48b9"
install_strategy = "bsipa-zip"
reason = "BeatMods verified IntroSkip 4.0.8 for Beat Saber 1.44.1 as version id 2570, zipHash 7d98ae6049251eb4e3226a5c8ac675b3. Use BeatMods CDN after the older GitHub 4.0.5 asset failed compatibility on this instance."
[[plugins]]
id = "soundreplacer"
repo = "Meivyn/SoundReplacer"
tag = "beatmods-2.0.1"
asset = "SoundReplacer-2.0.1.zip"
sha256 = "c14cc99cb0bd4ea4c3ab47345692489cbdfed8b2b577925200b1a8f6f4f93512"
install_strategy = "bsipa-zip"
reason = "BeatMods verified SoundReplacer 2.0.1 for Beat Saber 1.44.1 as version id 2213, zipHash 7d7a869996e10249d1f85f95e060319b. GitHub releases API returned no releases on 2026-06-29, so use the BeatMods CDN artifact."
[[plugins]]
id = "bettersonglist"
repo = "kinsi55/BeatSaber_BetterSongList"
tag = "v0.4.3"
asset = "BetterSongList.dll"
sha256 = "50993315f41e5ca8e83ce7ded6be7780cfbe62ce248023849bd898068a4e25c1"
install_strategy = "dll-to-plugins"
reason = "User-provided GitHub repository URL https://github.com/kinsi55/BeatSaber_BetterSongList. Latest non-draft, non-prerelease release v0.4.3 exposes this direct DLL asset; GitHub release digest matched the downloaded asset."
[[plugins]]
id = "hitscorevisualizer"
repo = "ErisApps/HitScoreVisualizer"
tag = "3.7.3"
asset = "HitScoreVisualizer-3.7.3-bs1.42.0-a565cbb.zip"
sha256 = "b889355cd47cc3b09a352f081110b8130ac6e1d285fa58776466117d585814db"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub repository URL https://github.com/ErisApps/HitScoreVisualizer. Latest non-draft, non-prerelease release 3.7.3 exposes this asset for Beat Saber 1.42.0; GitHub release digest matched the downloaded asset."
[[plugins]]
id = "hidethelogo"
repo = "TheBlackParrot/HideTheLogo"
tag = "1.0.3"
asset = "HideTheLogo-1.0.3-bs1.40.3-c968d91.zip"
sha256 = "f7177cf28add03ddd73fdbd720628755c7787f515523f2b92e0041c63e83d6ca"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub repository URL https://github.com/TheBlackParrot/HideTheLogo. Latest non-draft, non-prerelease release 1.0.3 exposes this asset for Beat Saber 1.40.3; GitHub release did not expose a digest."
[[plugins]]
id = "songchartvisualizer"
repo = "NuggoDEV/SongChartVisualizer"
tag = "beatmods-1.1.11"
asset = "SongChartVisualizer-1.1.11.zip"
sha256 = "290a55ff87d8769f3ae7c5d0b44a67e9e6e89e8ef83a2b1bb988ebea28f89da4"
install_strategy = "bsipa-zip"
reason = "BeatMods verified SongChartVisualizer 1.1.11 for Beat Saber 1.44.1 as version id 2249, zipHash 5d3fc025fe098277667fc0846e1b8fe3. User-provided GitHub repository URL https://github.com/NuggoDEV/SongChartVisualizer returned no releases through the GitHub releases API, so use the BeatMods CDN artifact."
[[plugins]]
id = "adblocker"
repo = "JonnyVR1/AdBlocker"
tag = "beatmods-1.0.5"
asset = "AdBlocker-1.0.5.zip"
sha256 = "0ee298563760d8c7f6ba7c8134982f9d1c2e554fe142648a3465051ac6468487"
install_strategy = "bsipa-zip"
reason = "BeatMods verified AdBlocker 1.0.5 for Beat Saber 1.44.1 as version id 1872, zipHash cd397e93b1a03f163534483462edf768. User-provided GitHub repository URL https://github.com/JonnyVR1/AdBlocker returned no releases through the GitHub releases API, so use the BeatMods CDN artifact."
[[plugins]]
id = "highlightbombs"
repo = "Meivyn/HighlightBombs"
tag = "beatmods-1.0.3"
asset = "HighlightBombs-1.0.3.zip"
sha256 = "fba7750632079dbce846ba57c2e3284fc72cb95671bff08394246e6664fe8113"
install_strategy = "bsipa-zip"
reason = "BeatMods verified HighlightBombs 1.0.3 for Beat Saber 1.44.1 as version id 2066, zipHash 4bedaa80ce5dda8414fea7d914fb94ad. GitHub latest release v1.0.1 is older than the BeatMods-verified 1.0.3 package, so use the BeatMods CDN artifact."
[[plugins]]
id = "pitchblack"
repo = "Loloppe/BeatSaber_PitchBlack"
tag = "0.03"
asset = "PitchBlack-v0.0.3-bs1.39.1.zip"
sha256 = "ae7269b4f40fea8bee3d2b213a67c3e779ff8bfbcb4f4deabcb9e9c21bfadafb"
install_strategy = "bsipa-zip"
reason = "BeatMods verified PitchBlack 0.0.3 for Beat Saber 1.44.1 as version id 2233, zipHash 65656bf33b2a0b356c381132387ea7ea. User-provided GitHub repository URL https://github.com/Loloppe/BeatSaber_PitchBlack tag 0.03 asset is byte-identical to the BeatMods CDN zip."
[[plugins]]
id = "reecamera"
repo = "Reezonate/ReeCamera"
tag = "v0.0.5"
asset = "ReeCamera.1.42.0.zip"
sha256 = "76fb8efa1103ed18d7913c63818cb2699785fb3718d394176d5e2a6938842c24"
install_strategy = "root-zip"
reason = "User-provided GitHub repository URL https://github.com/Reezonate/ReeCamera. Latest non-draft, non-prerelease release v0.0.5 offers ReeCamera.1.42.0.zip as the highest Beat Saber version asset for instance 1.44.1; GitHub release digest matched the downloaded asset. Archive bundles Plugins/ReeCamera.dll, bundled CameraUtils.dll, and UserData/ReeCamera presets."
[[plugins]]
id = "jdfixer"
repo = "zeph-yr/JDFixer"
tag = "pr-26-3fce6ce"
asset = "JDFixer.dll"
sha256 = "16b7dad9906d838dab40ce48a9b304be4847f18e700ddd31f2293d1065f4529d"
install_strategy = "dll-to-plugins"
reason = "Local build from GitHub PR https://github.com/zeph-yr/JDFixer/pull/26 at commit 3fce6ce465911bdd5e8e00411bc4672c54a317f7. Use this PR build instead of the failed upstream v.7.4.0 release asset, which loaded but failed OnEnable on Beat Saber 1.44.1."
+916
View File
@@ -21,3 +21,919 @@ 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 = "bettersonglist"
name = "BetterSongList"
repo = "kinsi55/BeatSaber_BetterSongList"
asset_patterns = ["BetterSongList.dll"]
install_strategy = "dll-to-plugins"
category = "ui"
[[plugins]]
id = "hitscorevisualizer"
name = "HitScoreVisualizer"
repo = "ErisApps/HitScoreVisualizer"
asset_patterns = ["HitScoreVisualizer-*.zip"]
install_strategy = "bsipa-zip"
category = "ui"
[[plugins.dependencies]]
id = "beatsabermarkuplanguage"
constraint = ">=1.14.1"
required = true
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "ditails"
name = "DiTails"
repo = "Auros/DiTails"
asset_patterns = ["DiTails-*.zip"]
install_strategy = "bsipa-zip"
category = "ui"
[[plugins.dependencies]]
id = "songcore"
constraint = ">=3.16.0"
required = true
[[plugins.dependencies]]
id = "beatsaversharp"
constraint = ">=3.4.5"
required = true
[[plugins.dependencies]]
id = "beatsabermarkuplanguage"
constraint = ">=1.14.1"
required = true
[[plugins.dependencies]]
id = "sirautil"
constraint = ">=3.3.1"
required = true
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "hidethelogo"
name = "HideTheLogo"
repo = "TheBlackParrot/HideTheLogo"
asset_patterns = ["HideTheLogo-*.zip"]
install_strategy = "bsipa-zip"
category = "ui"
[[plugins]]
id = "songchartvisualizer"
name = "SongChartVisualizer"
repo = "NuggoDEV/SongChartVisualizer"
asset_patterns = ["SongChartVisualizer-*.zip"]
install_strategy = "bsipa-zip"
category = "ui"
[[plugins.dependencies]]
id = "beatsabermarkuplanguage"
constraint = ">=1.14.1"
required = true
[[plugins.dependencies]]
id = "sirautil"
constraint = ">=3.3.1"
required = true
[[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 = "heck"
name = "Heck"
repo = "Aeroluna/Heck"
asset_patterns = ["Heck-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins.dependencies]]
id = "customjsondata"
constraint = ">=2.6.8"
required = true
[[plugins]]
id = "lookupid"
name = "LookupID"
repo = "Aeroluna/Heck"
asset_patterns = ["LookupID-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins]]
id = "chroma"
name = "Chroma"
repo = "Aeroluna/Heck"
asset_patterns = ["Chroma-*.zip"]
install_strategy = "bsipa-zip"
category = "mapping"
[[plugins.dependencies]]
id = "heck"
constraint = ">=1.8.3"
required = true
[[plugins.dependencies]]
id = "customjsondata"
constraint = ">=2.6.8"
required = true
[[plugins.dependencies]]
id = "lookupid"
constraint = ">=1.0.1"
required = true
[[plugins]]
id = "noodleextensions"
name = "Noodle Extensions"
repo = "Aeroluna/Heck"
asset_patterns = ["NoodleExtensions-*.zip"]
install_strategy = "bsipa-zip"
category = "mapping"
[[plugins.dependencies]]
id = "heck"
constraint = ">=1.8.3"
required = true
[[plugins.dependencies]]
id = "customjsondata"
constraint = ">=2.6.8"
required = true
[[plugins]]
id = "camerautils"
name = "CameraUtils"
repo = "Reezonate/CameraUtils"
asset_patterns = ["CameraUtils-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "assetbundleloadingtools"
name = "AssetBundleLoadingTools"
repo = "nicoco007/AssetBundleLoadingTools"
asset_patterns = ["AssetBundleLoadingTools-*.zip"]
install_strategy = "root-zip"
category = "library"
[[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.dependencies]]
id = "system-io-compression"
constraint = ">=4.6.57"
required = true
[[plugins.dependencies]]
id = "system-io-compression-filesystem"
constraint = ">=4.7.3056"
required = true
[[plugins]]
id = "vivify"
name = "Vivify"
repo = "Aeroluna/Vivify"
asset_patterns = ["Vivify-*.zip"]
install_strategy = "bsipa-zip"
category = "mapping"
[[plugins.dependencies]]
id = "heck"
constraint = ">=1.8.3"
required = true
[[plugins.dependencies]]
id = "customjsondata"
constraint = ">=2.6.8"
required = true
[[plugins.dependencies]]
id = "camerautils"
constraint = ">=1.0.8"
required = true
[[plugins.dependencies]]
id = "assetbundleloadingtools"
constraint = ">=1.1.13"
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 = "scoresaber"
name = "ScoreSaber"
repo = "ScoreSaber/pc-mod"
asset_patterns = ["ScoreSaber-*.zip"]
install_strategy = "bsipa-zip"
category = "leaderboard"
[[plugins.dependencies]]
id = "beatsabermarkuplanguage"
constraint = ">=1.14.1"
required = true
[[plugins.dependencies]]
id = "sirautil"
constraint = ">=3.3.1"
required = true
[[plugins.dependencies]]
id = "songcore"
constraint = ">=3.16.0"
required = true
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "leaderboardcore"
name = "LeaderboardCore"
repo = "NSGolova/LeaderboardCore"
asset_patterns = ["LeaderboardCore-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins.dependencies]]
id = "beatsabermarkuplanguage"
constraint = ">=1.14.1"
required = true
[[plugins.dependencies]]
id = "sirautil"
constraint = ">=3.3.1"
required = true
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "protobuf-net"
name = "protobuf-net"
repo = "protobuf-net/protobuf-net"
asset_patterns = ["protobuf-net-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins]]
id = "songdetailscache"
name = "SongDetailsCache"
repo = "kinsi55/BeatSaber_SongDetails"
asset_patterns = ["SongDetailsCache.BS.Lib.zip"]
install_strategy = "bsipa-zip"
category = "library"
[[plugins.dependencies]]
id = "protobuf-net"
constraint = ">=3.0.102"
required = true
[[plugins]]
id = "songrankedbadge"
name = "SongRankedBadge"
repo = "qe201020335/SongRankedBadge"
asset_patterns = ["SongRankedBadge-*.zip"]
install_strategy = "bsipa-zip"
category = "ui"
[[plugins.dependencies]]
id = "songcore"
constraint = ">=3.16.0"
required = true
[[plugins.dependencies]]
id = "beatsabermarkuplanguage"
constraint = ">=1.14.1"
required = true
[[plugins.dependencies]]
id = "songdetailscache"
constraint = ">=1.4.0"
required = true
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "beatleader"
name = "BeatLeader"
repo = "BeatLeader/beatleader-mod"
asset_patterns = ["BeatLeader-*.zip"]
install_strategy = "bsipa-zip"
category = "leaderboard"
[[plugins.dependencies]]
id = "bs-utils"
constraint = ">=1.14.3"
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 = "bsipa"
constraint = ">=4.3.7"
required = true
[[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 = "playlistmanager"
name = "PlaylistManager"
repo = "rithik-b/PlaylistManager"
asset_patterns = ["PlaylistManager-*.zip"]
install_strategy = "bsipa-zip"
category = "playlist"
[[plugins.dependencies]]
id = "songcore"
constraint = ">=3.16.0"
required = true
[[plugins.dependencies]]
id = "sirautil"
constraint = ">=3.3.1"
required = true
[[plugins.dependencies]]
id = "beatsaberplaylistslib"
constraint = ">=1.7.2"
required = true
[[plugins.dependencies]]
id = "system-io-compression-filesystem"
constraint = ">=4.7.3056"
required = true
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins.dependencies]]
id = "beatsaversharp"
constraint = ">=3.4.5"
required = true
[[plugins.dependencies]]
id = "system-io-compression"
constraint = ">=4.6.57"
required = true
[[plugins.dependencies]]
id = "beatsabermarkuplanguage"
constraint = ">=1.14.1"
required = true
[[plugins]]
id = "beatsavervoting"
name = "BeatSaverVoting"
repo = "Top-Cat/BeatSaverVoting"
asset_patterns = ["BeatSaverVoting-*.zip"]
install_strategy = "bsipa-zip"
category = "downloader"
[[plugins.dependencies]]
id = "bs-utils"
constraint = ">=1.14.3"
required = true
[[plugins.dependencies]]
id = "songcore"
constraint = ">=3.16.0"
required = true
[[plugins.dependencies]]
id = "beatsabermarkuplanguage"
constraint = ">=1.14.1"
required = true
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
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
[[plugins]]
id = "introskip"
name = "IntroSkip"
repo = "Loloppe/Intro-Skip"
asset_patterns = ["IntroSkip-*.zip"]
install_strategy = "bsipa-zip"
category = "gameplay"
[[plugins.dependencies]]
id = "beatsabermarkuplanguage"
constraint = ">=1.14.1"
required = true
[[plugins.dependencies]]
id = "sirautil"
constraint = ">=3.3.1"
required = true
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "failbutton"
name = "FailButton"
repo = "qe201020335/FailButton"
asset_patterns = ["FailButton-*.zip"]
install_strategy = "bsipa-zip"
category = "gameplay"
[[plugins]]
id = "easyoffset"
name = "EasyOffset"
repo = "Reezonate/EasyOffset"
asset_patterns = ["EasyOffset.dll"]
install_strategy = "dll-to-plugins"
category = "gameplay"
[[plugins]]
id = "gottagofast"
name = "GottaGoFast"
repo = "kinsi55/CS_BeatSaber_GottaGoFast"
asset_patterns = ["GottaGoFast.dll"]
install_strategy = "dll-to-plugins"
category = "gameplay"
[[plugins]]
id = "hitsoundtweaks"
name = "HitsoundTweaks"
repo = "GalaxyMaster2/HitsoundTweaks"
asset_patterns = ["HitsoundTweaks-*.zip"]
install_strategy = "bsipa-zip"
category = "gameplay"
[[plugins]]
id = "keepmyoverridespls"
name = "KeepMyOverridesPls"
repo = "qqrz997/KeepMyOverridesPls"
asset_patterns = ["KeepMyOverridesPls-*.zip"]
install_strategy = "bsipa-zip"
category = "gameplay"
[[plugins]]
id = "soundreplacer"
name = "SoundReplacer"
repo = "Meivyn/SoundReplacer"
asset_patterns = ["SoundReplacer-*.zip"]
install_strategy = "bsipa-zip"
category = "gameplay"
[[plugins.dependencies]]
id = "sirautil"
constraint = ">=3.3.1"
required = true
[[plugins.dependencies]]
id = "beatsabermarkuplanguage"
constraint = ">=1.14.1"
required = true
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "keyremapper"
name = "KeyRemapper"
repo = "lyyQwQ/KeyRemapper"
asset_patterns = ["KeyRemapper-*.zip"]
install_strategy = "bsipa-zip"
category = "gameplay"
[[plugins.dependencies]]
id = "beatsabermarkuplanguage"
constraint = ">=1.14.1"
required = true
[[plugins.dependencies]]
id = "sirautil"
constraint = ">=3.3.1"
required = true
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "squattobegin"
name = "SquatToBegin"
repo = "kinsi55/BeatSaber_SquatToBegin"
asset_patterns = ["SquatToBegin.dll"]
install_strategy = "dll-to-plugins"
category = "gameplay"
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "adblocker"
name = "AdBlocker"
repo = "JonnyVR1/AdBlocker"
asset_patterns = ["AdBlocker-*.zip"]
install_strategy = "bsipa-zip"
category = "cosmetic"
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "highlightbombs"
name = "HighlightBombs"
repo = "Meivyn/HighlightBombs"
asset_patterns = ["HighlightBombs-*.zip", "HighlightBombs.dll"]
install_strategy = "bsipa-zip"
category = "cosmetic"
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins.dependencies]]
id = "sirautil"
constraint = ">=3.3.1"
required = true
[[plugins]]
id = "pitchblack"
name = "PitchBlack"
repo = "Loloppe/BeatSaber_PitchBlack"
asset_patterns = ["PitchBlack-*.zip"]
install_strategy = "bsipa-zip"
category = "lighting"
[[plugins.dependencies]]
id = "beatsabermarkuplanguage"
constraint = ">=1.14.1"
required = true
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "reecamera"
name = "ReeCamera"
repo = "Reezonate/ReeCamera"
asset_patterns = ["ReeCamera.*.zip"]
install_strategy = "root-zip"
category = "camera"
[[plugins.dependencies]]
id = "camerautils"
constraint = ">=1.0.8"
required = true
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "jdfixer"
name = "JDFixer"
repo = "zeph-yr/JDFixer"
asset_patterns = ["JDFixer.dll"]
install_strategy = "dll-to-plugins"
category = "gameplay"
[[plugins.dependencies]]
id = "beatsabermarkuplanguage"
constraint = ">=1.14.1"
required = true
[[plugins.dependencies]]
id = "sirautil"
constraint = ">=3.3.1"
required = true
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class BeatModsEntry:
name: str
mod_id: int | None
git_url: str | None
category: str | None
version_id: int | None
mod_version: str | None
zip_hash: str | None
dependencies: tuple[int, ...]
raw: dict[str, Any]
def extract_mods(payload: Any) -> list[dict[str, Any]]:
"""Return the list of BeatMods records from either current or legacy shapes."""
if isinstance(payload, dict) and isinstance(payload.get("mods"), list):
payload = payload["mods"]
if not isinstance(payload, list):
raise ValueError("BeatMods payload must be a list or an object with a mods list")
return [item for item in payload if isinstance(item, dict)]
def normalize_entry(entry: dict[str, Any]) -> BeatModsEntry:
"""Normalize BeatMods' nested {mod, latest} response and older flat records."""
mod = entry.get("mod") if isinstance(entry.get("mod"), dict) else entry
latest = entry.get("latest") if isinstance(entry.get("latest"), dict) else entry
dependencies = latest.get("dependencies", ())
return BeatModsEntry(
name=str(mod.get("name") or ""),
mod_id=_int_or_none(mod.get("id")),
git_url=_str_or_none(mod.get("gitUrl")),
category=_str_or_none(mod.get("category")),
version_id=_int_or_none(latest.get("id")),
mod_version=_str_or_none(latest.get("modVersion")),
zip_hash=_str_or_none(latest.get("zipHash")),
dependencies=tuple(_dependency_id(dep) for dep in dependencies if _dependency_id(dep) is not None),
raw=entry,
)
def normalize_mods(payload: Any) -> list[BeatModsEntry]:
return [normalize_entry(entry) for entry in extract_mods(payload)]
def by_version_id(entries: list[BeatModsEntry]) -> dict[int, BeatModsEntry]:
return {entry.version_id: entry for entry in entries if entry.version_id is not None}
def _dependency_id(dependency: Any) -> int | None:
if isinstance(dependency, dict):
return _int_or_none(dependency.get("id"))
return _int_or_none(dependency)
def _int_or_none(value: Any) -> int | None:
if isinstance(value, bool) or value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _str_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value)
return text if text else None
+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:
+393 -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 restore_windows_data_repo, 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,23 @@ 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")
restore = subcommands.add_parser(
"restore-userdata",
help="Restore UserData and AppData from the backups repo into an instance",
parents=[_common_parent()],
)
restore.add_argument("--instance", required=True)
restore.add_argument("--backup-root", default="../backups/beat-saber", help="Backup directory")
restore.add_argument("--appdata-path", help="Override Beat Saber AppData destination path")
restore.add_argument("--no-appdata", action="store_true", help="Only restore UserData")
return parser
@@ -106,17 +265,116 @@ 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", "Copies UserData and AppData into the adjacent backups repo."),
("restore-userdata", "Restore UserData", "Restores UserData and AppData from the backups repo into an instance."),
("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 +388,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 +408,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 +454,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 +557,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 +567,43 @@ 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
if args.command == "restore-userdata":
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 = restore_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["restored"]:
print(f"{item['label']}: {item['fileCount']} files")
print(f" {item['source']} -> {item['destination']}")
if item["snapshot"]:
print(f" previous: {item['snapshot']}")
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,
}
+188 -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,164 @@ 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 infer_proton_appdata_path() -> Path:
return (
Path.home()
/ ".local/share/BSManager/SharedContent/compatdata/pfx/drive_c/users/steamuser/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber"
)
def infer_appdata_path(instance_path: Path) -> Path:
if "Users" in instance_path.resolve().parts:
return infer_windows_appdata_path(instance_path)
return infer_proton_appdata_path()
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_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 restore_windows_data_repo(
*,
instance: str,
instance_path: Path,
backup_root: Path,
appdata_path: Path | None = None,
include_appdata: bool = True,
) -> dict[str, Any]:
descriptor_path = backup_root / "backup-descriptor.json"
if descriptor_path.is_file():
descriptor = json.loads(descriptor_path.read_text(encoding="utf-8"))
descriptor_instance = descriptor.get("instance")
if descriptor_instance and descriptor_instance != instance:
raise ValueError(
f"backup descriptor instance {descriptor_instance!r} does not match requested instance {instance!r}"
)
created_at = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
restores: list[tuple[str, Path, Path]] = [
("UserData", backup_root / "UserData", instance_path / "UserData"),
]
if include_appdata:
appdata = appdata_path or infer_appdata_path(instance_path)
restores.append(("AppData", backup_root / "AppData", appdata))
for label, source, _ in restores:
if not source.is_dir():
raise FileNotFoundError(f"{label} backup not found: {source}")
restored: list[dict[str, Any]] = []
snapshots: list[dict[str, Any]] = []
for label, source, destination in restores:
snapshot: Path | None = None
if destination.exists():
snapshot = destination.parent / f"{destination.name}.pre-restore-{created_at}"
destination.rename(snapshot)
snapshots.append({"label": label, "path": str(snapshot)})
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(source, destination, symlinks=True)
restored.append(
{
"label": label,
"source": str(source),
"destination": str(destination),
"fileCount": sum(1 for item in destination.rglob("*") if item.is_file()),
"snapshot": str(snapshot) if snapshot else None,
}
)
return {
"backupRoot": str(backup_root),
"restored": restored,
"snapshots": snapshots,
}
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)
+616 -6
View File
@@ -1,22 +1,87 @@
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.beatmods import by_version_id, normalize_mods
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_appdata_path,
infer_proton_appdata_path,
infer_windows_appdata_path,
restore_windows_data_repo,
sync_windows_data_repo,
)
class PluginHelperTests(unittest.TestCase):
def test_normalize_beatmods_current_nested_response(self) -> None:
payload = {
"mods": [
{
"mod": {
"id": 10,
"name": "Example",
"gitUrl": "https://github.com/example/mod",
"category": "library",
},
"latest": {
"id": 1234,
"modVersion": "1.2.3",
"zipHash": "abc123",
"dependencies": [2561, {"id": "2567"}],
},
}
]
}
entries = normalize_mods(payload)
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0].name, "Example")
self.assertEqual(entries[0].mod_id, 10)
self.assertEqual(entries[0].version_id, 1234)
self.assertEqual(entries[0].dependencies, (2561, 2567))
self.assertEqual(by_version_id(entries)[1234].zip_hash, "abc123")
def test_normalize_beatmods_legacy_flat_response(self) -> None:
entries = normalize_mods(
[
{
"id": "12",
"name": "FlatExample",
"gitUrl": "",
"category": "mods",
"modVersion": "2.0.0",
"zipHash": "def456",
"dependencies": [{"id": 44}, "45", None],
}
]
)
self.assertEqual(entries[0].name, "FlatExample")
self.assertEqual(entries[0].mod_id, 12)
self.assertEqual(entries[0].version_id, 12)
self.assertIsNone(entries[0].git_url)
self.assertEqual(entries[0].dependencies, (44, 45))
def test_instances_and_scan(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
@@ -37,6 +102,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 +192,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 +249,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 +290,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 +340,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 +379,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 +488,152 @@ 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_infer_proton_appdata_path(self) -> None:
self.assertEqual(
infer_proton_appdata_path(),
Path.home()
/ ".local/share/BSManager/SharedContent/compatdata/pfx/drive_c/users/steamuser/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber",
)
def test_infer_appdata_path_uses_windows_or_proton(self) -> None:
windows_instance = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances/1.44.1")
linux_instance = Path("/home/pleb/.local/share/BSManager/BSInstances/1.44.1")
self.assertEqual(infer_appdata_path(windows_instance), infer_windows_appdata_path(windows_instance))
self.assertEqual(infer_appdata_path(linux_instance), infer_proton_appdata_path())
def test_restore_windows_data_repo_roundtrip(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('{"saved": true}', encoding="utf-8")
appdata.mkdir(parents=True)
(appdata / "settings.cfg").write_text("settings", encoding="utf-8")
sync_windows_data_repo(
instance="1.44.1",
instance_path=instance,
backup_root=backup_repo,
)
(instance / "UserData" / "settings.json").write_text('{"saved": false}', encoding="utf-8")
(appdata / "settings.cfg").write_text("changed", encoding="utf-8")
result = restore_windows_data_repo(
instance="1.44.1",
instance_path=instance,
backup_root=backup_repo,
)
self.assertEqual((instance / "UserData" / "settings.json").read_text(), '{"saved": true}')
self.assertEqual((appdata / "settings.cfg").read_text(), "settings")
self.assertEqual(len(result["restored"]), 2)
self.assertTrue(all(item["snapshot"] for item in result["restored"]))
def test_restore_windows_data_repo_rejects_instance_mismatch(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
instance = root / "1.44.1"
backup_repo = root / "backup"
(instance / "UserData").mkdir(parents=True)
(instance / "UserData" / "settings.json").write_text("{}", encoding="utf-8")
(backup_repo / "UserData").mkdir(parents=True)
(backup_repo / "UserData" / "settings.json").write_text("{}", encoding="utf-8")
(backup_repo / "backup-descriptor.json").write_text(
json.dumps({"instance": "1.40.8"}) + "\n",
encoding="utf-8",
)
with self.assertRaisesRegex(ValueError, "does not match"):
restore_windows_data_repo(
instance="1.44.1",
instance_path=instance,
backup_root=backup_repo,
include_appdata=False,
)
def test_restore_userdata_cli(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
instances_root = root / "instances"
instance = instances_root / "1.44.1"
backup_repo = root / "backup"
(instance / "UserData").mkdir(parents=True)
(instance / "UserData" / "settings.json").write_text('{"restored": true}', encoding="utf-8")
(backup_repo / "UserData").mkdir(parents=True)
(backup_repo / "UserData" / "settings.json").write_text('{"restored": true}', encoding="utf-8")
status = run(
[
"--instances-root",
str(instances_root),
"--state-dir",
str(root / "state"),
"restore-userdata",
"--instance",
"1.44.1",
"--backup-root",
str(backup_repo),
"--no-appdata",
]
)
self.assertEqual(status, 0)
self.assertEqual((instance / "UserData" / "settings.json").read_text(), '{"restored": true}')
def test_check_reports_missing_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
@@ -239,6 +671,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()