Compare commits

...

18 Commits

Author SHA1 Message Date
pleb 407abfe6ec Simplify plugin-helper state configuration 2026-07-01 13:43:39 -07:00
pleb 1be1353835 Plan for Windows compat 2026-07-01 13:24:18 -07:00
pleb c0a167dc67 Use plugin-helper profiles in builder skill 2026-07-01 12:45:58 -07:00
pleb eed12376c6 Default interactive CLI to menu 2026-07-01 11:59:01 -07:00
pleb 69f9dbd9b1 Add profile-aware plugin TUI 2026-07-01 11:44:03 -07:00
pleb 13b1840ba0 feat: add plugin disable and enable commands 2026-07-01 10:42:18 -07:00
pleb de0b20fa61 chore: update 1.44.1 plugin lock state 2026-07-01 10:42:15 -07:00
pleb fec4c82a5d docs: update plugin helper agent workflow 2026-07-01 10:42:09 -07:00
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
37 changed files with 5641 additions and 238 deletions
@@ -0,0 +1,126 @@
---
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 .NET Framework BSIPA projects.
---
# Beat Saber Plugin Builder
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
sed -n '1,220p' plugin-helper.local.toml
```
In `plugin-helper`, run commands from repo root. Treat
`plugin-helper.local.toml` as the source of truth for profile
`instances_root` and `state_dir` values, and keep temporary source checkouts
under the chosen profile's `state_dir` such as
`<state_dir>/build/<name>`. Do not disturb unrelated dirty files.
2. Resolve source.
For a GitHub PR, clone or reuse a checkout under the selected profile's
`<state_dir>/build`, add/fetch the upstream remote if needed, and check out
the PR head:
```bash
git clone https://github.com/<owner>/<repo>.git <state_dir>/build/<name>
git -C <state_dir>/build/<name> fetch origin pull/<pr>/head:pr-<pr>
git -C <state_dir>/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`.
Read `plugin-helper.local.toml` and select the intended profile, then use
that profile's `instances_root` and matching `state_dir` instead of
searching default BSManager paths manually:
```bash
PYTHONPATH=src .venv/bin/python -m plugin_helper --profile <profile-id> instances
```
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 the package matching the project's target framework, usually
`Microsoft.NETFramework.ReferenceAssemblies.net48` for current BSIPA
projects, as described in the reference file.
6. Verify Beat Saber API changes before patching gameplay code.
When a Beat Saber upgrade breaks a type/member reference, first determine
whether the API moved assemblies before substituting another type. Inspect
the target game DLLs and nearby mod sources:
```bash
strings "<BeatSaberDir>/Beat Saber_Data/Managed/Main.dll" | rg 'TypeOrMemberName'
strings "<BeatSaberDir>/Beat Saber_Data/Managed/HMLib.dll" | rg 'TypeOrMemberName'
ilspycmd -t TypeName "<BeatSaberDir>/Beat Saber_Data/Managed/Main.dll" | sed -n '1,220p'
rg -n 'TypeOrMemberName|NearbyConcept' ~/src/<owner>/<repo> ~/src/Auros/SiraUtil ~/src/nike4613/BeatSaber-IPA-Reloaded
```
7. 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_dir>/instances/<instance>/downloads/<plugin-id>/` for the selected
profile and use the helper plan/apply workflow rather than hand-copying into
a BSManager instance.
8. 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 .venv/bin/python -m compileall -q src tests
PYTHONPATH=src .venv/bin/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 package matching the target framework, usually `Microsoft.NETFramework.ReferenceAssemblies.net48` for current projects, 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,134 @@
# 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 .NET Framework class libraries; current projects
commonly target `net48`, while older projects may still target `net472`.
- 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
```
In `plugin-helper`, prefer the profile selected from
`plugin-helper.local.toml` as the source of truth for the managed instance root
and state directory:
```bash
sed -n '1,220p' plugin-helper.local.toml
PYTHONPATH=src .venv/bin/python -m plugin_helper --profile <profile-id> instances
```
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>/path/from/selected/profile/instances_root/1.44.1</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 the `Microsoft.NETFramework.ReferenceAssemblies.*` package matching the
project target framework, usually `Microsoft.NETFramework.ReferenceAssemblies.net48`
for current projects, 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.x` reference assemblies: add
the matching `Microsoft.NETFramework.ReferenceAssemblies.*` package, usually
`Microsoft.NETFramework.ReferenceAssemblies.net48` for current projects.
- 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_dir>/instances/<instance>/downloads/<plugin-id>
cp <checkout>/<path>/bin/Release/<Plugin>.dll <state_dir>/instances/<instance>/downloads/<plugin-id>/<Plugin>.dll
sha256sum <state_dir>/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 .venv/bin/python -m plugin_helper --profile <profile-id> check --instance <instance>
PYTHONPATH=src .venv/bin/python -m plugin_helper --profile <profile-id> plan --instance <instance> --plugin <plugin-id>
PYTHONPATH=src .venv/bin/python -m plugin_helper --profile <profile-id> apply <plan-path>
```
Inspect the generated plan before applying.
@@ -0,0 +1,257 @@
---
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.
---
# Beat Saber Plugin Installer
Use the repository's own `plugin-helper` commands to manage plugins for BSManager instances whenever the helper supports the operation.
## 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.
## 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: interface:
display_name: "Install Beat Saber Plugin" display_name: "Install Beat Saber Plugin"
short_description: "Update Beat Saber plugins via helper" 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.
+4
View File
@@ -1,4 +1,7 @@
/.state/ /.state/
/.state-*/
/plugin-helper.local.toml
/plugin-helper.windows.toml
/.pytest_cache/ /.pytest_cache/
/build/ /build/
/dist/ /dist/
@@ -6,3 +9,4 @@
/src/*.egg-info/ /src/*.egg-info/
/__pycache__/ /__pycache__/
*.pyc *.pyc
.venv
+84
View File
@@ -0,0 +1,84 @@
# 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:
- `~/Windows/Users/pleb/BSManager/BSInstances`
- `~/.local/share/BSManager/BSInstances`
- A local BSManager source checkout may be available at
`~/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.
- Keep plugin source checkouts under `~/src/<owner>/<repo>` when a
locked or registry plugin has a GitHub source repo. Prefer checking out the
upstream author/repo first, with `origin` pointing at upstream. If the user has
an existing personal fork checkout, preserve it as a remote named `github`
and set/add `origin` to the upstream repo instead of replacing local work.
- 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`.
- A repo-local Python virtualenv is normally available at `.venv`; prefer
`.venv/bin/python` for helper commands and tests when it exists.
- For human-style inspection, prefer the menu with repo-local state:
`PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state menu`.
- When targeting the local Linux BSManager install, pass
`--instances-root ~/.local/share/BSManager/BSInstances` and normally
`--state-dir .state`.
- When targeting the mounted Windows BSManager install, pass
`--instances-root ~/Windows/Users/pleb/BSManager/BSInstances` and
normally `--state-dir .state-windows`.
- Use the helper commands instead of manually copying plugin files into an
instance.
- When adding, updating, building, or investigating a GitHub-hosted plugin,
check for `~/src/<owner>/<repo>` and clone the upstream repo there if
it is missing. Do not substitute forks or similar repos without explicit user
direction.
- 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.
- After completing repo changes, suggest a concise commit message in the final
response unless the user already asked you to commit.
## Validation
- Run `PYTHONPATH=src .venv/bin/python -m unittest discover -s tests` after
code changes when `.venv` exists; otherwise use `python`.
- Run `PYTHONPATH=src .venv/bin/python -m compileall -q src tests` for
syntax/import checks when `.venv` exists; otherwise use `python`.
- For live game validation, follow `docs/SMOKETEST.md` and tear down Beat Saber
processes afterward.
## Pull Requests
- When the user asks for a PR against an upstream plugin, compose and submit a
polite pull request using the user's existing `gh` session.
- Keep upstream PRs small, focused, and easy for the maintainer to verify. Favor
narrow compatibility fixes, clear commit messages, and minimal formatting or
project-file churn.
- Work from the upstream checkout under `~/src/<owner>/<repo>` when available.
If the upstream remote is not writable, create or reuse the user's fork with
`gh repo fork`, push a topic branch there, and open the PR against upstream.
- Include concise verification notes in the PR body, especially the exact build
or smoke-test command and any generated artifact name. Mention local-only
build configuration separately if it was needed, and do not commit machine
paths or helper state unless the user explicitly asks for it.
## 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.
+157 -11
View File
@@ -1,6 +1,6 @@
# plugin-helper # plugin-helper
`plugin-helper` is an early Python CLI for managing Beat Saber plugins in a mounted Windows BSManager install. `plugin-helper` is an early Python CLI for managing Beat Saber plugins in BSManager installs.
The first implementation focuses on safe local workflows: The first implementation focuses on safe local workflows:
@@ -8,30 +8,176 @@ The first implementation focuses on safe local workflows:
- scan existing `Plugins/` and `Libs/` files - scan existing `Plugins/` and `Libs/` files
- read checked-in registry and per-version lockfiles - read checked-in registry and per-version lockfiles
- generate a machine-readable install plan from local release assets - 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 - uninstall only files recorded in install state
- back up `UserData` separately
Default BSManager instance root: Default BSManager instance root:
```text ```text
/home/pleb/Windows/Users/pleb/BSManager/BSInstances /home/pleb/.local/share/BSManager/BSInstances
``` ```
Override with `--instances-root` or `PLUGIN_HELPER_INSTANCES_ROOT`. Default plugin-helper state directory:
```text
$XDG_STATE_HOME/plugin-helper
```
If `XDG_STATE_HOME` is not set, the state directory defaults to:
```text
~/.local/state/plugin-helper
```
Override the instance root with `--instances-root`,
`PLUGIN_HELPER_INSTANCES_ROOT`, or `plugin-helper.local.toml`. To search
multiple explicit roots, separate them with `:`.
Override the state directory with `--state-dir`, `PLUGIN_HELPER_STATE_DIR`, or
`plugin-helper.local.toml`.
## Local Configuration
This checkout is intended to manage the local Linux BSManager install. If you
also manage a Windows install, use a separate clone on that partition and point
both clones at the same state directory only when you intentionally want one
shared source of truth.
Copy the example config and adjust paths if needed:
## Quick Start
```sh ```sh
python -m plugin_helper instances cp plugin-helper.toml.example plugin-helper.local.toml
python -m plugin_helper scan --instance 1.40.8
python -m plugin_helper plan --instance 1.40.8 --state-dir .state
``` ```
`plugin-helper.local.toml` is ignored by git and uses top-level fields:
```toml
instances_root = "~/.local/share/BSManager/BSInstances"
state_dir = "~/.local/state/plugin-helper"
```
For repo-local state, set:
```toml
state_dir = ".state"
```
For a shared Windows-partition state directory, set the same `state_dir` in both
clones, for example:
```toml
state_dir = "~/Windows/Users/pleb/ops/plugin-helper/.state"
```
CLI flags override environment variables, environment variables override local
config, and local config overrides built-in defaults.
## Commands
For normal use, run the Textual menu from the repo root:
```sh
PYTHONPATH=src python -m plugin_helper
```
That is equivalent to `PYTHONPATH=src python -m plugin_helper menu` when run
from an interactive terminal.
The menu reads `plugin-helper.local.toml` when present, shows each discovered
Beat Saber install with its resolved state directory, and lets you toggle
managed plugins with arrow keys and Space. In the plugin table, use `d` to
disable all currently enabled managed plugins and `e` to enable all currently
disabled managed plugins.
The individual subcommands are mostly for automation and debugging. If you use
them, pass `--state-dir` directly only when you intentionally want to override
the configured state directory for one command.
Install assets are currently expected to already exist locally, usually under: Install assets are currently expected to already exist locally, usually under:
```text ```text
.state/instances/<instance>/downloads/ <state-dir>/instances/<instance>/downloads/<plugin-id>/
``` ```
Future milestones will add GitHub release discovery and download. ## Beat Saber Data Backups
`backup-userdata` copies the mounted Windows `UserData` folder and Beat Saber
Windows app data into the adjacent `../backups` repo. With the Windows mount at
`~/Windows`, the helper infers Beat Saber's Windows app data as:
```text
/home/pleb/Windows/Users/pleb/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber
```
Example manual backup after mounting Windows:
```sh
PYTHONPATH=src python -m plugin_helper \
--instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances \
backup-userdata \
--instance 1.44.1
```
By default the backup repo receives plain copied files under
`../backups/beat-saber/UserData` and `../backups/beat-saber/AppData`, plus
`../backups/beat-saber/backup-descriptor.json` describing the source paths from
the latest backup run. Use
`--appdata-path <path>` if the Windows profile path ever differs, `--no-appdata`
for a `UserData`-only sync, or `--backup-root <path>` to choose a different
destination.
`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 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 ```text
/home/pleb/Windows/Users/pleb/BSManager/BSInstances /home/pleb/Windows/Users/pleb/BSManager/BSInstances
/home/pleb/.local/share/BSManager/BSInstances
``` ```
## Goals ## 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. - Pull plugin releases directly from configured GitHub repositories.
- Determine candidate updates while respecting the pinned Beat Saber version. - Determine candidate updates while respecting the pinned Beat Saber version.
- Support selective updates and explicit pins. - 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. - Replacing BSManager as a GUI or Beat Saber instance manager.
- Downloading or downgrading Beat Saber versions. - Downloading or downgrading Beat Saber versions.
- Running `nixos-rebuild switch`. - Running `nixos-rebuild switch`.
- Mutating the Windows partition unless the user has mounted it and explicitly runs an apply command. - Mutating the Windows partition unless the user has mounted it and explicitly
- Treating Nix as the plugin installer. Nix should package `plugin-helper`; the CLI should manage the mutable mounted game tree. 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 ## Core Model
@@ -76,15 +85,42 @@ Runtime state should not need to live inside the repository. By default, keep mu
installed.json installed.json
plans/ plans/
downloads/ downloads/
<plugin-id>/
backups/ backups/
``` ```
For early development, a `--state-dir` option is useful so plans and manifests can be kept in the repo while the format settles. 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 ## Registry
The registry describes plugin sources and install behavior. It should be human-editable because many Beat Saber plugins have small packaging differences. 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: Example:
```toml ```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. - Python owns instance discovery, dry-run plans, activation, install state, uninstall, and `UserData` backups.
- Release assets are selected through registry and lockfile data. - 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. - 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. - 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 ## Future: Nix-Orchestrated Plugin Sets
@@ -64,12 +74,25 @@ All modes should still produce the same kind of explicit plan before applying.
## Proposed Milestones ## Proposed Milestones
1. Keep the Python safety harness stable: scan, plan, apply, uninstall, and backups. 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. 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. Add a Nix function that fetches and unpacks one locked plugin asset into a normalized tree. 3. Resolve BeatMods dependency closures by mod-version id for verified mods before ordinary batch planning, but keep artifact sourcing GitHub-preferred.
4. Generate a full plugin-set derivation for one Beat Saber version. 4. Model one real plugin end to end with the current TOML lockfile and local asset planning.
5. Teach `plugin-helper plan` to compare a Nix output tree against an instance. 5. Add a Nix function that fetches and unpacks one locked plugin asset into a normalized tree.
6. Add `--activation-mode copy|symlink|materialize`. 6. Generate a full plugin-set derivation for one Beat Saber version.
7. Move compatibility and dependency metadata toward shared data that both Python and Nix can consume. 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 ## 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:#f85149; font-weight:600">failed compatibility trial</span> | Local PR82 build from `.state/build/playlistmanager-pr82-skilltest`, artifact `PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip`; removed from the 1.44.1 lock after DiTails smoketest failure | IPA loaded PlaylistManager 1.7.4, but later smoketest coverage showed it was still incompatible with this stack. Removed from both live BS installs and do not reinstall until a compatible build is available. |
| 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 | [github](https://github.com/Auros/BeatSaverSharper), [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`; source repo updated to `Auros/BeatSaverSharper`; locked package remains the BeatMods CDN artifact | 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), [pr](https://github.com/lyyQwQ/KeyRemapper/pull/6) | <span style="color:#f85149; font-weight:600">currently failed</span> | Local build from `/home/pleb/src/lyyQwQ/KeyRemapper` commit `23bb523`, asset `KeyRemapper-0.3.0-bs1.44.0-8e4c11a.zip`, SHA-256 `b8aaaa72712ae10cec98f51f79b3b87fe7a588b0d3393dd087b7d84975246e6c`; PR 6 retargets the pause/menu Affinity patch from removed `UnityXRHelper` to `DevicelessVRHelper` and sets manifest gameVersion to 1.44.0. PR 6 was converted to draft after the failed manual smoke. | Replaces the prior GitHub `0.3.0` Beat Saber 1.39.1 asset. Helper installed `Plugins/KeyRemapper.dll` SHA-256 `07839dd28fa4bfbd88a3b72a8f77ebd22e70ac5acdc1239c47b9deabf775d8cd` and `Plugins/KeyRemapper.pdb` SHA-256 `e118c71519b632027f958d902d5ebcd9efb4851530091639cdbfaa96d43d4cb8`. User manual smoke on 2026-07-01 found the configured `UserData/KeyRemapper.json` pause bindings (`L_X`, `L_Y`, `R_A`, `R_B`) were not remapped. Windows logs show KeyRemapper loaded and read config without a KeyRemapper exception; `2026.07.01.09.53.35.log.gz` found both controllers and installed `KeyRemapper.Installers.GameplayInstaller`, but gameplay then flooded `MissingMethodException: AudioTimeSyncController/InitData .AudioTimeSyncController.get_state()`. TODO: add explicit logging around `GameplayInstaller`'s `IVRPlatformHelper.vrPlatformSDK == VRPlatformSDK.OpenXR` gate and verify whether that gate prevents `RemapBaseGameMenuButton`/`RestartHandler` from binding despite `UnityXRInputManager` being selected from `XRSettings.loadedDeviceName`. |
| SquatToBegin | [github](https://github.com/kinsi55/BeatSaber_SquatToBegin) | <span style="color:#d29922; font-weight:600">installed; manual resmoke pending</span> | Local build from `/home/pleb/src/kinsi55/BeatSaber_SquatToBegin` at commit `8703b84` plus local 1.44 compatibility fixes; asset `SquatToBegin.dll`, SHA-256 `0bd6b5ee48cae370f255707afb9e452e08a68802f81493dd9a442b6a9f7b98bd`. BeatMods verified search on 2026-07-01 found no SquatToBegin entry for Beat Saber 1.44.1, 1.44.0, 1.43.0, 1.42.1, 1.42.0, 1.40.0, 1.39.1, or 1.20.0. | Replaces the prior GitHub `v0.0.7` DLL and the first local build. IPA loaded SquatToBegin 0.0.7, but the automated smoke did not reach the menu because Steam platform initialization failed. User manual smoke on 2026-07-01 found the squat gate did not arm and songs started immediately. Follow-up build keeps the 1.44 `IAudioTimeSource.State.Playing` fix and arms the gate from gameplay scene setup instead of relying on the stale level-selection prefix; verify VR behavior manually. |
| 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:#d29922; font-weight:600">installed; smoke blocked</span> | Local build from `/home/pleb/src/Auros/DiTails` commit `601e3c4` on branch `fix-1.44-artwork-initialization`, asset `DiTails-1.1.3-bs1.44.1-601e3c4.zip`, SHA-256 `b8735f24545f1a50392865bf3013b930bee8e4ba7feac824a2482e2f1deeba1e`; replaces failed GitHub `Auros/DiTails` tag `1.1.3`, asset `DiTails-v1.1.3-g1.42.0-271d394.zip` and BeatMods 1.1.3 trial | Helper installed `Plugins/DiTails.dll` SHA-256 `5c856ffdfeab54982b84cb2e3033c970622668c553396460499d2343363d1d9d` in the mounted Windows instance. IPA loaded DiTails 1.1.3 and the previous `DetailContextManager.Initialize()` `NullReferenceException` did not recur before startup was blocked by `SteamAPI Init failed` because Steam was not running. |
| 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/Auros/BeatSaverSharper
- 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
+77
View File
@@ -0,0 +1,77 @@
# Windows Compatibility Tracker
Started: 2026-07-01
This note tracks the changes needed to run `plugin-helper` natively on Windows
11 with Python 3.13, separate from the Linux helper that manages mounted
Windows instances.
## Target Setup
- Install Python with:
```powershell
winget install --id Python.Python.3.13 --exact
```
- Use a separate checkout or working copy on the Windows partition.
- Use a Windows-specific config file:
```powershell
py -3.13 -m plugin_helper --config plugin-helper.windows.toml --profile windows instances
```
- Keep native Windows state local to that checkout, such as `.state/`, so it
does not mix with the Linux `.state` or mounted-Windows `.state-windows`
directories.
## Current Compatibility Notes
- Core scan, plan, apply, uninstall, disable, and enable flows are mostly
platform-neutral. They use `pathlib`, `zipfile`, `shutil`, JSON/TOML, and
local file hashes.
- Native Windows defaults should not reuse Linux-mounted paths such as
`/home/pleb/Windows/...`. A Windows-specific TOML file handles this for normal
use.
- Multiple `--instances-root` values use `os.pathsep`; that means `;` on
Windows and `:` on Linux. Documentation should make this platform-specific.
- The Textual TUI dependency supports Windows and Python 3.13, but the best
terminal target is Windows Terminal or a modern PowerShell host.
## Work Items
- Add native Windows bootstrap support.
- Current bootstrap assumes Proton.
- Native Windows should run `IPA.exe -n` directly from the Beat Saber instance.
- Timeout cleanup needs Windows-compatible process handling instead of
POSIX process groups.
- Decide whether `bootstrap-check` should accept a recorded native Windows
bootstrap state without a Proton launch history.
- Add Windows-aware default paths or keep requiring `--config
plugin-helper.windows.toml` for native use.
- Update README examples for PowerShell:
- editable install
- `--config plugin-helper.windows.toml`
- Windows path-list separator `;`
- Add or adjust tests for Windows behavior.
- Skip or rewrite the POSIX-only `_run_ipa` timeout test on Windows.
- Add tests for native Windows config path resolution.
- Add tests for native bootstrap command construction.
- Review backup and restore helpers on native Windows.
- `sync_windows_data_repo` and `restore_windows_data_repo` should work with
ordinary Windows paths.
- The older tar-based `backup_userdata` helper uses `NamedTemporaryFile` in a
way that may not be Windows-friendly if it becomes part of the CLI later.
## First Manual Smoke
From the Windows checkout:
```powershell
py -3.13 -m pip install -e .
py -3.13 -m plugin_helper --config plugin-helper.windows.toml --profile windows instances
py -3.13 -m plugin_helper --config plugin-helper.windows.toml --profile windows installed --instance 1.44.1
```
If instance discovery fails, verify the BSManager instance root in
`plugin-helper.windows.toml`.
+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 = "Auros/BeatSaverSharper"
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. Source repository is now Auros/BeatSaverSharper; this remains a BeatMods CDN fallback for the locked 3.4.5 package."
[[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 = "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 = "pr-6-23bb523-1.44-fix"
asset = "KeyRemapper-0.3.0-bs1.44.0-8e4c11a.zip"
sha256 = "b8aaaa72712ae10cec98f51f79b3b87fe7a588b0d3393dd087b7d84975246e6c"
install_strategy = "bsipa-zip"
reason = "Local build from /home/pleb/src/lyyQwQ/KeyRemapper commit 23bb523, submitted upstream as PR https://github.com/lyyQwQ/KeyRemapper/pull/6. This updates the menu remap Affinity patch target from removed UnityXRHelper to DevicelessVRHelper and sets manifest gameVersion to 1.44.0 for Beat Saber 1.44 compatibility."
[[plugins]]
id = "squattobegin"
repo = "kinsi55/BeatSaber_SquatToBegin"
tag = "localbuild-8703b84-1.44-fix"
asset = "SquatToBegin.dll"
sha256 = "0bd6b5ee48cae370f255707afb9e452e08a68802f81493dd9a442b6a9f7b98bd"
install_strategy = "dll-to-plugins"
reason = "Local build from /home/pleb/src/kinsi55/BeatSaber_SquatToBegin at commit 8703b84 plus local 1.44 compatibility fixes: change AudioTimeSyncController.State.Playing to IAudioTimeSource.State.Playing and arm the squat gate from gameplay scene setup instead of relying on the stale level-selection prefix. BeatMods verified search on 2026-07-01 found no SquatToBegin entry for Beat Saber 1.44.1, 1.44.0, 1.43.0, 1.42.1, 1.42.0, 1.40.0, 1.39.1, or 1.20.0."
[[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 = "ditails"
repo = "Auros/DiTails"
tag = "localbuild-601e3c4-1.44-fix"
asset = "DiTails-1.1.3-bs1.44.1-601e3c4.zip"
sha256 = "b8735f24545f1a50392865bf3013b930bee8e4ba7feac824a2482e2f1deeba1e"
install_strategy = "bsipa-zip"
reason = "Local build from /home/pleb/src/Auros/DiTails commit 601e3c4 on branch fix-1.44-artwork-initialization. Use this build instead of the failed upstream 1.1.3 / BeatMods DiTails trial, which loaded but failed menu initialization with a NullReferenceException on Beat Saber 1.44.1."
[[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."
+8
View File
@@ -0,0 +1,8 @@
instances_root = "~/.local/share/BSManager/BSInstances"
state_dir = "~/.local/state/plugin-helper"
# To keep state inside this checkout instead:
# state_dir = ".state"
#
# To share one state directory between Linux and a Windows-partition checkout:
# state_dir = "~/Windows/Users/pleb/ops/plugin-helper/.state"
+3 -1
View File
@@ -10,7 +10,9 @@ readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
license = "MIT" license = "MIT"
authors = [{ name = "plugin-helper contributors" }] authors = [{ name = "plugin-helper contributors" }]
dependencies = [] dependencies = [
"textual>=8.2,<9",
]
[project.scripts] [project.scripts]
plugin-helper = "plugin_helper.cli:main" plugin-helper = "plugin_helper.cli:main"
+916
View File
@@ -21,3 +21,919 @@ repo = "not-dexter/accsaber-reloaded-plugin"
asset_patterns = ["1.40.8.zip"] asset_patterns = ["1.40.8.zip"]
install_strategy = "bsipa-zip" install_strategy = "bsipa-zip"
category = "leaderboard" 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 = "Auros/BeatSaverSharper"
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: if not locked.asset:
messages.append({"level": "error", "message": "missing asset"}) messages.append({"level": "error", "message": "missing asset"})
else: 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: if not asset_path:
messages.append({"level": "error", "message": "asset not found in downloads or repo assets"}) messages.append({"level": "error", "message": "asset not found in downloads or repo assets"})
elif locked.sha256 and sha256_file(asset_path) != locked.sha256: elif locked.sha256 and sha256_file(asset_path) != locked.sha256:
+341 -18
View File
@@ -6,21 +6,59 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from .config import instances_root, repo_root, state_root from .config import repo_root, resolve_runtime_config
from .bootstrap import run_bootstrap
from .bsipa import check_bsipa_health
from .checker import check_lock from .checker import check_lock
from .installer import apply_plan, uninstall_plugin from .github import fetch_releases
from .installer import apply_plan, disable_plugin, uninstall_plugin
from .instances import get_instance, list_instances from .instances import get_instance, list_instances
from .models import load_lockfile, load_registry from .models import load_lockfile, load_registry
from .operations import enable_disabled_plugin
from .planner import create_plan from .planner import create_plan
from .reports import installed_plugins_report, print_installed_plugins
from .scanner import scan_instance from .scanner import scan_instance
from .state import load_installed_state 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: def _json(data: Any) -> None:
print(json.dumps(data, indent=2, sort_keys=True)) print(json.dumps(data, indent=2, sort_keys=True))
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: def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = False) -> None:
default = argparse.SUPPRESS if suppress_default else None default = argparse.SUPPRESS if suppress_default else None
parser.add_argument("--instances-root", default=default, help="BSManager instances root") parser.add_argument("--instances-root", default=default, help="BSManager instances root")
@@ -30,7 +68,7 @@ def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = Fal
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="plugin-helper") parser = argparse.ArgumentParser(prog="plugin-helper")
_add_common(parser) _add_common(parser)
subcommands = parser.add_subparsers(dest="command", required=True) subcommands = parser.add_subparsers(dest="command")
subcommands.add_parser( subcommands.add_parser(
"instances", "instances",
@@ -38,6 +76,12 @@ def build_parser() -> argparse.ArgumentParser:
parents=[_common_parent()], parents=[_common_parent()],
) )
subcommands.add_parser(
"menu",
help="Open an interactive instance/action menu",
parents=[_common_parent()],
)
scan = subcommands.add_parser( scan = subcommands.add_parser(
"scan", "scan",
help="Inspect installed Plugins, Libs, and IPA/Pending files", help="Inspect installed Plugins, Libs, and IPA/Pending files",
@@ -54,6 +98,16 @@ def build_parser() -> argparse.ArgumentParser:
) )
state.add_argument("--instance", required=True) 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 = subcommands.add_parser(
"check", "check",
help="Validate local registry, lockfile, and release asset readiness", help="Validate local registry, lockfile, and release asset readiness",
@@ -64,6 +118,37 @@ def build_parser() -> argparse.ArgumentParser:
check.add_argument("--lockfile") check.add_argument("--lockfile")
check.add_argument("--json", action="store_true", help="Print full JSON check output") check.add_argument("--json", action="store_true", help="Print full JSON check output")
bootstrap = subcommands.add_parser(
"bootstrap",
help="Install locked BSIPA, run IPA.exe -n through Proton, and record bootstrap files",
parents=[_common_parent()],
)
bootstrap.add_argument("--instance", required=True)
bootstrap.add_argument("--registry", default="registry/plugins.toml")
bootstrap.add_argument("--lockfile")
bootstrap.add_argument("--proton", help="Path to Proton executable")
bootstrap.add_argument("--json", action="store_true", help="Print full JSON bootstrap output")
bootstrap_check = subcommands.add_parser(
"bootstrap-check",
help="Verify recorded BSIPA bootstrap state and latest IPA log",
parents=[_common_parent()],
)
bootstrap_check.add_argument("--instance", required=True)
bootstrap_check.add_argument("--json", action="store_true", help="Print full JSON bootstrap health output")
updates = subcommands.add_parser(
"updates",
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 = subcommands.add_parser(
"plan", "plan",
help="Create a dry-run install plan from registry and lockfile", help="Create a dry-run install plan from registry and lockfile",
@@ -90,12 +175,44 @@ def build_parser() -> argparse.ArgumentParser:
uninstall.add_argument("plugin") uninstall.add_argument("plugin")
uninstall.add_argument("--force", action="store_true", help="Delete even when current file hashes differ") uninstall.add_argument("--force", action="store_true", help="Delete even when current file hashes differ")
disable = subcommands.add_parser(
"disable",
help="Remove a managed plugin's files from the game but keep state/assets for re-enable",
parents=[_common_parent()],
)
disable.add_argument("--instance", required=True)
disable.add_argument("plugin")
disable.add_argument("--force", action="store_true", help="Disable even when current file hashes differ")
enable = subcommands.add_parser(
"enable",
help="Reinstall a disabled locked plugin from local assets",
parents=[_common_parent()],
)
enable.add_argument("--instance", required=True)
enable.add_argument("--registry", default="registry/plugins.toml")
enable.add_argument("--lockfile")
enable.add_argument("plugin")
backup = subcommands.add_parser( backup = subcommands.add_parser(
"backup-userdata", "backup-userdata",
help="Create a timestamped UserData backup archive", help="Copy UserData and Windows AppData into the adjacent backups repo",
parents=[_common_parent()], parents=[_common_parent()],
) )
backup.add_argument("--instance", required=True) backup.add_argument("--instance", required=True)
backup.add_argument("--backup-root", default="../backups/beat-saber", help="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 return parser
@@ -106,17 +223,73 @@ def _common_parent() -> argparse.ArgumentParser:
return parent return parent
def _run_menu(
*,
runtime: Any,
explicit_instances_root: bool,
explicit_state_dir: bool,
) -> int:
try:
from .tui import InstallationChoice, PluginHelperTui
except ImportError as exc:
print(f"Textual is required for the menu TUI: {exc}")
print("Install project dependencies, for example: python -m pip install -e .")
return 1
choices: list[InstallationChoice] = []
for index, root in enumerate(runtime.instances_roots, start=1):
install_label = str(root) if len(runtime.instances_roots) > 1 else "Default"
for instance in list_instances(root):
choices.append(
InstallationChoice(
install_id=f"root-{index}",
install_label=install_label,
instance_name=instance.name,
instance_path=instance.path,
state_root=runtime.state_root,
)
)
if not choices:
searched = ", ".join(str(root) for root in runtime.instances_roots)
print(f"No Beat Saber instances found under {searched}")
return 1
setup_hint = None
if not runtime.config_loaded and not explicit_instances_root and not explicit_state_dir:
setup_hint = (
f"No {runtime.config_path.name} found; using default roots and default state. "
f"Copy plugin-helper.toml.example to {runtime.config_path.name} to set a custom state dir."
)
app = PluginHelperTui(choices=choices, repo_root=repo_root(), setup_hint=setup_hint)
result = app.run()
return int(result or 0)
def run(argv: list[str] | None = None) -> int: def run(argv: list[str] | None = None) -> int:
parser = build_parser() parser = build_parser()
args = parser.parse_args(argv) args = parser.parse_args(argv)
inst_root = instances_root(getattr(args, "instances_root", None))
st_root = state_root(getattr(args, "state_dir", None))
try: try:
if args.command is None:
if sys.stdin.isatty() and sys.stdout.isatty():
args.command = "menu"
else:
parser.print_help()
return 2
explicit_instances_root = getattr(args, "instances_root", None) is not None
explicit_state_dir = getattr(args, "state_dir", None) is not None
runtime = resolve_runtime_config(
instances_root_value=getattr(args, "instances_root", None),
state_dir_value=getattr(args, "state_dir", None),
)
inst_roots = runtime.instances_roots
st_root = runtime.state_root
if args.command == "instances": if args.command == "instances":
found = list_instances(inst_root) found = list_instances(inst_roots)
if not found: if not found:
print(f"No Beat Saber instances found under {inst_root}") print(f"No Beat Saber instances found under {', '.join(str(root) for root in inst_roots)}")
return 1 return 1
for item in found: for item in found:
flags = [] flags = []
@@ -130,8 +303,15 @@ def run(argv: list[str] | None = None) -> int:
print(f"{item.name}\t{item.path}{suffix}") print(f"{item.name}\t{item.path}{suffix}")
return 0 return 0
if args.command == "menu":
return _run_menu(
runtime=runtime,
explicit_instances_root=explicit_instances_root,
explicit_state_dir=explicit_state_dir,
)
if args.command == "scan": if args.command == "scan":
instance = get_instance(inst_root, args.instance) instance = get_instance(inst_roots, args.instance)
result = scan_instance(instance.path, include_hashes=args.hashes) result = scan_instance(instance.path, include_hashes=args.hashes)
if args.json: if args.json:
_json(result) _json(result)
@@ -147,6 +327,23 @@ def run(argv: list[str] | None = None) -> int:
_json(load_installed_state(st_root, args.instance)) _json(load_installed_state(st_root, args.instance))
return 0 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": if args.command == "check":
root = repo_root() root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry) registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
@@ -176,8 +373,76 @@ def run(argv: list[str] | None = None) -> int:
print(f" {message['level']}: {message['message']}") print(f" {message['level']}: {message['message']}")
return 2 if result["summary"]["errors"] else 0 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": if args.command == "plan":
instance = get_instance(inst_root, args.instance) instance = get_instance(inst_roots, args.instance)
root = repo_root() root = repo_root()
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry) registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml" lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
@@ -211,7 +476,7 @@ def run(argv: list[str] | None = None) -> int:
return 0 return 0
if args.command == "uninstall": if args.command == "uninstall":
instance = get_instance(inst_root, args.instance) instance = get_instance(inst_roots, args.instance)
result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force) result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force)
print(f"Removed: {len(result['removed'])}") print(f"Removed: {len(result['removed'])}")
if result["skipped"]: if result["skipped"]:
@@ -220,13 +485,71 @@ def run(argv: list[str] | None = None) -> int:
print(f" {item['path']}: {item['reason']}") print(f" {item['path']}: {item['reason']}")
return 0 if result["stateUpdated"] else 2 return 0 if result["stateUpdated"] else 2
if args.command == "disable":
instance = get_instance(inst_roots, args.instance)
result = disable_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force)
print(f"Disabled: {args.plugin}")
print(f"Removed: {len(result['removed'])}")
if result["skipped"]:
print("Skipped:")
for item in result["skipped"]:
print(f" {item['path']}: {item['reason']}")
return 0 if result["stateUpdated"] else 2
if args.command == "enable":
instance = get_instance(inst_roots, args.instance)
result = enable_disabled_plugin(
instance=args.instance,
instance_path=instance.path,
state_root=st_root,
plugin_id=args.plugin,
registry=args.registry,
lockfile=args.lockfile,
)
print(f"Enabled: {args.plugin}")
print(f"Plan: {result['planPath']}")
print(f"Applied: {len(result['applied'])}")
print(f"State: {result['statePath']}")
return 0
if args.command == "backup-userdata": if args.command == "backup-userdata":
instance = get_instance(inst_root, args.instance) instance = get_instance(inst_roots, args.instance)
result = backup_userdata(args.instance, instance.path, st_root) root = repo_root()
manifest = result["manifest"] backup_root = Path(args.backup_root).expanduser()
print(f"Archive: {result['archive']}") if not backup_root.is_absolute():
print(f"Files: {manifest['fileCount']}") backup_root = (root / backup_root).resolve()
print(f"Bytes: {manifest['totalSize']}") result = sync_windows_data_repo(
instance=args.instance,
instance_path=instance.path,
backup_root=backup_root,
appdata_path=Path(args.appdata_path).expanduser() if args.appdata_path else None,
include_appdata=not args.no_appdata,
)
print(f"Backup root: {result['backupRoot']}")
for item in result["copied"]:
print(f"{item['label']}: {item['fileCount']} files")
print(f" {item['source']} -> {item['destination']}")
return 0
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 return 0
except Exception as exc: except Exception as exc:
+124 -5
View File
@@ -1,24 +1,143 @@
from __future__ import annotations from __future__ import annotations
import os import os
import tomllib
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any
DEFAULT_INSTANCES_ROOT = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances") LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances"
DEFAULT_INSTANCES_ROOT = LOCAL_INSTANCES_ROOT
LOCAL_CONFIG_NAME = "plugin-helper.local.toml"
@dataclass(frozen=True)
class LocalConfig:
instances_roots: list[Path] | None
state_root: Path | None
@dataclass(frozen=True)
class RuntimeConfig:
instances_roots: list[Path]
state_root: Path
config_path: Path
config_loaded: bool
def repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def instances_root(value: str | None = None) -> Path: def instances_root(value: str | None = None) -> Path:
return instances_roots(value)[0]
def instances_roots(value: str | None = None, *, base: Path | None = None) -> list[Path]:
raw = value or os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT") raw = value or os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT")
return Path(raw).expanduser() if raw else DEFAULT_INSTANCES_ROOT if raw:
return _resolve_path_list(raw, base or repo_root())
return [DEFAULT_INSTANCES_ROOT]
def state_root(value: str | None = None) -> Path: def state_root(value: str | None = None) -> Path:
if value: if value:
return Path(value).expanduser() return _resolve_path(value, repo_root())
env_state = os.environ.get("PLUGIN_HELPER_STATE_DIR")
if env_state:
return _resolve_path(env_state, repo_root())
xdg_state = os.environ.get("XDG_STATE_HOME") xdg_state = os.environ.get("XDG_STATE_HOME")
base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state" base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state"
return base / "plugin-helper" return base / "plugin-helper"
def repo_root() -> Path: def default_config_path(root: Path | None = None) -> Path:
return Path(__file__).resolve().parents[2] return (root or repo_root()) / LOCAL_CONFIG_NAME
def _resolve_path(value: str | Path, base: Path) -> Path:
path = Path(value).expanduser()
if path.is_absolute():
return path
return (base / path).resolve()
def _resolve_path_list(value: str, base: Path) -> list[Path]:
return [_resolve_path(item, base) for item in value.split(os.pathsep) if item]
def load_local_config(config_path: str | Path | None = None, *, root: Path | None = None) -> tuple[LocalConfig, Path, bool]:
repo = root or repo_root()
path = _resolve_path(config_path, repo) if config_path else default_config_path(repo)
if not path.exists():
if config_path:
raise FileNotFoundError(f"plugin-helper config not found: {path}")
return LocalConfig(instances_roots=None, state_root=None), path, False
with path.open("rb") as handle:
data: dict[str, Any] = tomllib.load(handle)
base = path.parent
instances_value = data.get("instances_root")
state_value = data.get("state_dir")
return (
LocalConfig(
instances_roots=_resolve_path_list(instances_value, base) if instances_value else None,
state_root=_resolve_path(state_value, base) if state_value else None,
),
path,
True,
)
def _env_instances_roots(root: Path) -> list[Path] | None:
value = os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT")
return _resolve_path_list(value, root) if value else None
def _env_state_root(root: Path) -> Path | None:
value = os.environ.get("PLUGIN_HELPER_STATE_DIR")
return _resolve_path(value, root) if value else None
def _default_state_root() -> Path:
xdg_state = os.environ.get("XDG_STATE_HOME")
base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state"
return base / "plugin-helper"
def _default_instances_roots() -> list[Path]:
return [DEFAULT_INSTANCES_ROOT]
def resolve_runtime_config(
*,
instances_root_value: str | None = None,
state_dir_value: str | None = None,
root: Path | None = None,
) -> RuntimeConfig:
repo = root or repo_root()
local_config, loaded_path, loaded = load_local_config(root=repo)
resolved_instances = (
_resolve_path_list(instances_root_value, repo)
if instances_root_value
else _env_instances_roots(repo)
or local_config.instances_roots
or _default_instances_roots()
)
resolved_state = (
_resolve_path(state_dir_value, repo)
if state_dir_value
else _env_state_root(repo)
or local_config.state_root
or _default_state_root()
)
return RuntimeConfig(
instances_roots=resolved_instances,
state_root=resolved_state,
config_path=loaded_path,
config_loaded=loaded,
)
+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
+35
View File
@@ -77,6 +77,7 @@ def apply_plan(plan: dict[str, Any], state_root: Path) -> dict[str, Any]:
"size": target.stat().st_size, "size": target.stat().st_size,
} }
) )
installed_state.setdefault("disabledPlugins", {}).pop(change["plugin"], None)
applied.append({"path": rel_target, "plugin": change["plugin"], "backup": backup}) applied.append({"path": rel_target, "plugin": change["plugin"], "backup": backup})
save_installed_state(state_root, instance, installed_state) save_installed_state(state_root, instance, installed_state)
@@ -110,3 +111,37 @@ def uninstall_plugin(instance: str, instance_path: Path, state_root: Path, plugi
installed_state.get("plugins", {}).pop(plugin_id, None) installed_state.get("plugins", {}).pop(plugin_id, None)
save_installed_state(state_root, instance, installed_state) save_installed_state(state_root, instance, installed_state)
return {"removed": removed, "skipped": skipped, "stateUpdated": True} return {"removed": removed, "skipped": skipped, "stateUpdated": True}
def disable_plugin(instance: str, instance_path: Path, state_root: Path, plugin_id: str, force: bool = False) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance)
plugin_state = installed_state.get("plugins", {}).get(plugin_id)
if not plugin_state:
if plugin_id in installed_state.get("disabledPlugins", {}):
raise KeyError(f"plugin is already disabled: {plugin_id}")
raise KeyError(f"plugin is not recorded in install state: {plugin_id}")
removed: list[str] = []
skipped: list[dict[str, str]] = []
for item in plugin_state.get("files", []):
rel_path = ensure_relative(item["path"]).as_posix()
target = ensure_inside(instance_path, instance_path / rel_path)
if not target.exists():
removed.append(rel_path)
continue
current_sha = sha256_file(target)
if current_sha != item.get("sha256") and not force:
skipped.append({"path": rel_path, "reason": "hash mismatch"})
continue
target.unlink()
removed.append(rel_path)
if skipped and not force:
return {"removed": removed, "skipped": skipped, "stateUpdated": False}
disabled_state = dict(plugin_state)
disabled_state["disabledAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
installed_state.setdefault("disabledPlugins", {})[plugin_id] = disabled_state
installed_state.get("plugins", {}).pop(plugin_id, None)
save_installed_state(state_root, instance, installed_state)
return {"removed": removed, "skipped": skipped, "stateUpdated": True}
+44 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from collections.abc import Sequence
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -13,6 +14,9 @@ class Instance:
has_userdata: bool has_userdata: bool
RootInput = Path | Sequence[Path]
def looks_like_instance(path: Path) -> bool: def looks_like_instance(path: Path) -> bool:
return ( return (
(path / "Beat Saber_Data").is_dir() (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(): if not root.exists():
return [] return []
instances: list[Instance] = [] instances: list[Instance] = []
@@ -41,7 +51,20 @@ def list_instances(root: Path) -> list[Instance]:
return instances 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 path = root / name
if not path.is_dir() or not looks_like_instance(path): if not path.is_dir() or not looks_like_instance(path):
raise FileNotFoundError(f"Beat Saber instance not found: {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_libs=(path / "Libs").is_dir(),
has_userdata=(path / "UserData").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]
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from .config import repo_root
from .installer import apply_plan
from .models import load_lockfile, load_registry
from .planner import create_plan
from .state import load_installed_state
def enable_disabled_plugin(
*,
instance: str,
instance_path: Path,
state_root: Path,
plugin_id: str,
registry: str = "registry/plugins.toml",
lockfile: str | None = None,
repo: Path | None = None,
) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance)
if plugin_id not in installed_state.get("disabledPlugins", {}):
raise KeyError(f"plugin is not recorded as disabled: {plugin_id}")
root = repo or repo_root()
registry_path = (root / registry).resolve() if not Path(registry).is_absolute() else Path(registry)
lock_path = Path(lockfile) if lockfile else root / "locks" / f"{instance}.lock.toml"
if not lock_path.is_absolute():
lock_path = (root / lock_path).resolve()
loaded_lockfile = load_lockfile(lock_path)
if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins):
raise KeyError(f"plugin is disabled but not locked for this instance: {plugin_id}")
plan, path = create_plan(
instance=instance,
instance_path=instance_path,
beat_saber_version=loaded_lockfile.beat_saber_version,
registry=load_registry(registry_path),
lockfile=loaded_lockfile,
state_root=state_root,
repo_root=root,
selected={plugin_id},
)
result = apply_plan(plan, state_root)
return {"planPath": str(path), **result}
+18 -5
View File
@@ -9,7 +9,8 @@ from zipfile import ZipFile
from .fsutil import ensure_relative, sha256_bytes, sha256_file from .fsutil import ensure_relative, sha256_bytes, sha256_file
from .models import Lockfile, Registry, VALID_STRATEGIES 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"} 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") 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 = [ candidates = [
Path(asset).expanduser(), Path(asset).expanduser(),
downloads_dir(state_root, instance) / asset,
repo_root / "assets" / asset, repo_root / "assets" / asset,
repo_root / "locks" / "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: for candidate in candidates:
if candidate.exists() and candidate.is_file(): if candidate.exists() and candidate.is_file():
return candidate return candidate
@@ -72,11 +75,20 @@ def create_plan(
state_root: Path, state_root: Path,
repo_root: Path, repo_root: Path,
selected: set[str] | None = None, selected: set[str] | None = None,
require_bootstrap: bool = True,
) -> tuple[dict[str, Any], Path]: ) -> tuple[dict[str, Any], Path]:
selected_ids = selected or {plugin.id for plugin in lockfile.plugins} selected_ids = selected or {plugin.id for plugin in lockfile.plugins}
changes: list[dict[str, Any]] = [] changes: list[dict[str, Any]] = []
warnings: list[str] = [] 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: for locked in lockfile.plugins:
if locked.id not in selected_ids: if locked.id not in selected_ids:
continue continue
@@ -91,10 +103,11 @@ def create_plan(
if registry_plugin and not _asset_matches_patterns(Path(locked.asset).name, registry_plugin.asset_patterns): 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") 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: if not asset_path:
raise FileNotFoundError( 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) asset_sha = sha256_file(asset_path)
if locked.sha256 and locked.sha256 != asset_sha: if locked.sha256 and locked.sha256 != asset_sha:
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from typing import Any
from .models import Lockfile, Registry
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]] = []
state_plugins = [
(plugin_id, plugin_state, "enabled")
for plugin_id, plugin_state in installed_state.get("plugins", {}).items()
]
state_plugins.extend(
(plugin_id, plugin_state, "disabled")
for plugin_id, plugin_state in installed_state.get("disabledPlugins", {}).items()
)
for plugin_id, plugin_state, status in sorted(state_plugins):
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)"),
"disabledAt": plugin_state.get("disabledAt"),
"status": status,
"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", "Status", "Version", "Asset", "Files", "Installed")
rows = [
(
f"{plugin['name']} ({plugin['id']})",
plugin["status"],
plugin["version"],
plugin["asset"],
str(plugin["fileCount"]),
plugin["disabledAt"] or 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)))
+31
View File
@@ -7,6 +7,8 @@ from .fsutil import sha256_file
SCAN_DIRS = ("Plugins", "Libs", "IPA/Pending") 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]: 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/")), "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" 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]: def load_installed_state(state_root: Path, instance: str) -> dict[str, Any]:
return read_json( return read_json(
installed_state_path(state_root, instance), 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: def save_installed_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
state.setdefault("instance", instance) state.setdefault("instance", instance)
state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") 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 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: def backups_dir(state_root: Path, instance: str) -> Path:
path = instance_state_dir(state_root, instance) / "backups" path = instance_state_dir(state_root, instance) / "backups"
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
+275
View File
@@ -0,0 +1,275 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from rich.text import Text
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import DataTable, Footer, Header, Static
from .installer import disable_plugin
from .models import load_lockfile, load_registry
from .operations import enable_disabled_plugin
from .reports import installed_plugins_report
from .state import load_installed_state
@dataclass(frozen=True)
class InstallationChoice:
install_id: str
install_label: str
instance_name: str
instance_path: Path
state_root: Path
class PluginHelperTui(App[int]):
CSS = """
#title {
padding: 0 1;
text-style: bold;
}
#status {
padding: 0 1;
color: $text-muted;
}
"""
BINDINGS = [
Binding("enter", "select", "Select", priority=True),
Binding("space", "toggle_plugin", "Toggle", priority=True),
Binding("d", "disable_all_plugins", "Disable all", priority=True),
Binding("e", "enable_all_plugins", "Enable all", priority=True),
Binding("r", "refresh", "Refresh"),
Binding("b", "back", "Back"),
Binding("q", "quit", "Quit"),
Binding("ctrl+q", "quit", "Quit", show=False, priority=True),
]
def __init__(
self,
*,
choices: list[InstallationChoice],
repo_root: Path,
setup_hint: str | None = None,
) -> None:
super().__init__()
self.choices = choices
self.repo_root = repo_root
self.setup_hint = setup_hint
self.mode = "installations"
self.selected_installation: InstallationChoice | None = None
self.plugin_rows: list[dict[str, Any]] = []
self.status_message = ""
def compose(self) -> ComposeResult:
yield Header(show_clock=False)
yield Static("", id="title")
yield DataTable(id="table")
yield Static("", id="status")
yield Footer()
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.cursor_type = "row"
self._show_installations()
def action_select(self) -> None:
if self.mode != "installations":
return
index = self._cursor_index(len(self.choices))
if index is None:
return
self.selected_installation = self.choices[index]
self._show_plugins()
def action_back(self) -> None:
if self.mode == "plugins":
self._show_installations()
def action_refresh(self) -> None:
if self.mode == "plugins":
self._show_plugins()
else:
self._show_installations()
def action_toggle_plugin(self) -> None:
if self.mode != "plugins" or self.selected_installation is None:
return
index = self._cursor_index(len(self.plugin_rows))
if index is None:
return
plugin = self.plugin_rows[index]
plugin_id = plugin["id"]
target = self.selected_installation
try:
if plugin["status"] == "enabled":
result = disable_plugin(
target.instance_name,
target.instance_path,
target.state_root,
plugin_id,
force=False,
)
if not result["stateUpdated"]:
self._set_status(f"Could not disable {plugin_id}: {self._format_skipped(result['skipped'])}")
return
self._set_status(f"Disabled {plugin_id}; removed {len(result['removed'])} files.")
elif plugin["status"] == "disabled":
result = enable_disabled_plugin(
instance=target.instance_name,
instance_path=target.instance_path,
state_root=target.state_root,
plugin_id=plugin_id,
repo=self.repo_root,
)
self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.")
else:
self._set_status(f"Cannot toggle {plugin_id}: unknown status {plugin['status']}.")
return
except Exception as exc:
self._set_status(f"Could not toggle {plugin_id}: {exc}")
return
self._show_plugins(preserve_status=True)
def action_disable_all_plugins(self) -> None:
if self.mode != "plugins" or self.selected_installation is None:
return
target = self.selected_installation
enabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "enabled"]
changed = 0
errors: list[str] = []
for plugin in enabled:
plugin_id = plugin["id"]
try:
result = disable_plugin(
target.instance_name,
target.instance_path,
target.state_root,
plugin_id,
force=False,
)
if result["stateUpdated"]:
changed += 1
else:
errors.append(f"{plugin_id}: {self._format_skipped(result['skipped'])}")
except Exception as exc:
errors.append(f"{plugin_id}: {exc}")
self._set_bulk_status("Disabled", changed, errors)
self._show_plugins(preserve_status=True)
def action_enable_all_plugins(self) -> None:
if self.mode != "plugins" or self.selected_installation is None:
return
target = self.selected_installation
disabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "disabled"]
changed = 0
errors: list[str] = []
for plugin in disabled:
plugin_id = plugin["id"]
try:
enable_disabled_plugin(
instance=target.instance_name,
instance_path=target.instance_path,
state_root=target.state_root,
plugin_id=plugin_id,
repo=self.repo_root,
)
changed += 1
except Exception as exc:
errors.append(f"{plugin_id}: {exc}")
self._set_bulk_status("Enabled", changed, errors)
self._show_plugins(preserve_status=True)
def _show_installations(self) -> None:
self.mode = "installations"
self.plugin_rows = []
self._set_title("Choose Beat Saber Installation")
table = self.query_one(DataTable)
table.clear(columns=True)
table.add_columns("Install", "Version", "Instance Path", "State Dir")
for choice in self.choices:
table.add_row(
choice.install_label,
choice.instance_name,
str(choice.instance_path),
str(choice.state_root),
)
if self.setup_hint:
self._set_status(self.setup_hint)
else:
self._set_status("Enter selects an installation. q quits.")
def _show_plugins(self, *, preserve_status: bool = False) -> None:
if self.selected_installation is None:
self._show_installations()
return
target = self.selected_installation
self.mode = "plugins"
self._set_title(f"{target.install_label} / {target.instance_name}")
table = self.query_one(DataTable)
table.clear(columns=True)
table.add_columns("Status", "Name", "ID", "Version", "Files", "Asset")
try:
lockfile = load_lockfile(self.repo_root / "locks" / f"{target.instance_name}.lock.toml")
report = installed_plugins_report(
installed_state=load_installed_state(target.state_root, target.instance_name),
registry=load_registry(self.repo_root / "registry" / "plugins.toml"),
lockfile=lockfile,
)
self.plugin_rows = report["plugins"]
except Exception as exc:
self.plugin_rows = []
if not preserve_status:
self._set_status(f"Could not load plugins: {exc}")
return
for plugin in self.plugin_rows:
table.add_row(
self._status_marker(plugin["status"]),
plugin["name"],
plugin["id"],
plugin["version"],
str(plugin["fileCount"]),
plugin["asset"],
)
if not preserve_status:
if self.plugin_rows:
self._set_status("Space toggles selected. d disables all. e enables all. b returns to installations.")
else:
self._set_status("No managed plugins recorded for this installation.")
def _cursor_index(self, row_count: int) -> int | None:
table = self.query_one(DataTable)
row = table.cursor_coordinate.row
if 0 <= row < row_count:
return row
return None
def _set_title(self, message: str) -> None:
self.query_one("#title", Static).update(message)
def _set_status(self, message: str) -> None:
self.status_message = message
self.query_one("#status", Static).update(message)
def _set_bulk_status(self, verb: str, changed: int, errors: list[str]) -> None:
if errors:
preview = "; ".join(errors[:3])
suffix = f"; {len(errors) - 3} more" if len(errors) > 3 else ""
self._set_status(f"{verb} {changed} plugins; {len(errors)} failed: {preview}{suffix}")
else:
self._set_status(f"{verb} {changed} plugins.")
@staticmethod
def _status_marker(status: str) -> Text:
return Text("[x]" if status == "enabled" else "[ ]", no_wrap=True)
@staticmethod
def _format_skipped(skipped: list[dict[str, str]]) -> str:
if not skipped:
return "no files changed"
return "; ".join(f"{item['path']} {item['reason']}" for item in skipped)
+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 from __future__ import annotations
import json import json
import fnmatch
import shutil
import tarfile import tarfile
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from typing import Any from typing import Any, Callable
from .fsutil import sha256_file from .fsutil import sha256_file
from .state import backups_dir from .state import backups_dir
DEFAULT_BACKUP_EXCLUDES = (
"BeatLeader/Replays",
"BeatLeader/Replays/**",
"BeatLeader/ReplayerCache",
"BeatLeader/ReplayerCache/**",
"BeatLeader/LeaderboardsCache",
"BeatLeader/LeaderboardsCache/**",
"BeatLeader/ReplayHeadersCache",
"ScoreSaber/Replays",
"ScoreSaber/Replays/**",
"BeatSaberPlus/Cache",
"BeatSaberPlus/Cache/**",
"BeatSaverNotifier.json",
"Accsaber/PlayerScoreCache.json",
"NalulunaAvatars/cache",
"NalulunaAvatars/cache/**",
"SongDetailsCache.proto",
"com.unity.addressables",
"com.unity.addressables/**",
"*.log",
"*.log.*",
)
def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dict[str, Any]: def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dict[str, Any]:
source = instance_path / "UserData" source = instance_path / "UserData"
if not source.is_dir(): if not source.is_dir():
@@ -44,3 +70,164 @@ def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dic
handle.flush() handle.flush()
archive.add(handle.name, arcname="manifest.json") archive.add(handle.name, arcname="manifest.json")
return {"archive": str(destination), "manifest": manifest} return {"archive": str(destination), "manifest": manifest}
def infer_windows_appdata_path(instance_path: Path) -> Path:
parts = instance_path.resolve().parts
try:
users_index = parts.index("Users")
except ValueError as exc:
raise ValueError(f"cannot infer Windows user profile from instance path: {instance_path}") from exc
if users_index + 1 >= len(parts):
raise ValueError(f"cannot infer Windows user profile from instance path: {instance_path}")
profile = Path(*parts[: users_index + 2])
return profile / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber"
def 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)
File diff suppressed because it is too large Load Diff