Compare commits

..

3 Commits

Author SHA1 Message Date
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
9 changed files with 295 additions and 47 deletions
@@ -5,10 +5,7 @@ description: Install or update a Beat Saber plugin in the plugin-helper repo by
# Install Beat Saber Plugin
Use the repository's own `plugin-helper` commands to manage plugins for BSManager instances whenever the helper supports the operation. Do not manually copy release files into the game instance except:
- to bootstrap BSIPA/core packages before the helper has a first-class bootstrap command
- to undo your own mistaken install before rerunning the helper
Use the repository's own `plugin-helper` commands to manage plugins for BSManager instances whenever the helper supports the operation.
## Hard Guardrail
@@ -37,14 +34,6 @@ such as .NET assemblies. Record the artifact source plus BeatMods `modVersion`,
version id, `zipHash`, dependencies, and supported game version in the repo
notes/lock data.
Accepted URL shapes include:
```text
https://github.com/<owner>/<repo>/releases
https://github.com/<owner>/<repo>/releases/tag/<tag>
https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
```
## Workflow
1. Confirm the workspace is the `plugin-helper` repo.
+29 -5
View File
@@ -6,13 +6,18 @@ Guidance for coding agents working in this repo.
- This repo manages Beat Saber plugins for BSManager instances.
- Default instance roots are:
- `/home/pleb/Windows/Users/pleb/BSManager/BSInstances`
- `/home/pleb/.local/share/BSManager/BSInstances`
- `~/Windows/Users/pleb/BSManager/BSInstances`
- `~/.local/share/BSManager/BSInstances`
- A local BSManager source checkout may be available at
`/home/pleb/src/Zagrios/bs-manager`. Use it as a read-only reference when
`~/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
@@ -24,13 +29,17 @@ Guidance for coding agents working in this repo.
- For human-style inspection, prefer the menu with repo-local state:
`PYTHONPATH=src python -m plugin_helper --state-dir .state menu`.
- When targeting the local Linux BSManager install, pass
`--instances-root /home/pleb/.local/share/BSManager/BSInstances` and normally
`--instances-root ~/.local/share/BSManager/BSInstances` and normally
`--state-dir .state`.
- When targeting the mounted Windows BSManager install, pass
`--instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances` and
`--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.
@@ -46,6 +55,21 @@ Guidance for coding agents working in this repo.
- 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.
@@ -151,11 +151,11 @@ Purpose: restore in-game song discovery and playlist management.
| Plugin | Upstream | Status | Source/version | Verification notes |
| --- | --- | --- | --- | --- |
| BeatSaverDownloader | [beatmods zip](https://beatmods.com/cdn/mod/a740c6e68a9b5d1dfda3cc8e81f7cf06.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 6.0.7, version id 2217, zipHash `a740c6e68a9b5d1dfda3cc8e81f7cf06`; BeatMods preferred repo `Top-Cat/BeatSaverDownloader` exposes no release assets through the GitHub releases API | IPA loaded BeatSaver Downloader 6.0.7 and started its internal webserver. Warning: it probed for missing `BetterSongList.dll` with IPA library-loader `CRITICAL` lines, then continued. |
| PlaylistManager | [github](https://github.com/rithik-b/PlaylistManager) | <span style="color:#3fb950; font-weight:600">verified</span> | Local PR82 build from `.state/build/playlistmanager-pr82-skilltest`, artifact `PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip`; replaces failed BeatMods 1.7.3 compatibility trial | IPA loaded PlaylistManager 1.7.4, installed `PlaylistManagerAppInstaller`, and reached `MainSystemInit` during smoketest. The old `IPlatformUserModel` / `PlatformUserModel` failure did not recur. |
| 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 | [beatmods zip](https://beatmods.com/cdn/mod/be37e13e93d9ac7da4efbdc3f514fa8f.zip) | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 3.4.5, version id 1831, zipHash `be37e13e93d9ac7da4efbdc3f514fa8f`; BeatMods preferred repo `lolPants/BeatSaverSharp` was inaccessible through the GitHub releases API | IPA loaded BeatSaverSharp 3.4.5. |
| 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. |
@@ -188,8 +188,8 @@ Purpose: add small gameplay helpers two or three at a time.
| HitsoundTweaks | [github](https://github.com/GalaxyMaster2/HitsoundTweaks) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | GitHub `GalaxyMaster2/HitsoundTweaks` tag `v1.1.9`, asset `HitsoundTweaks-1.1.9-bs1.40.3-4ad8461.zip`; GitHub release digest matched downloaded asset | IPA loaded HitsoundTweaks 1.1.9, but SiraUtil failed to apply the `AudioTimeSyncController_dspTimeOffset_Patch` affinity patch with `InvalidProgramException`. Removed from the live instance after the failed smoke. BeatMods verified search on 2026-06-29 found no `HitsoundTweaks` entry for Beat Saber 1.42.0, 1.42.1, 1.43.0, 1.44.0, or 1.44.1. |
| KeepMyOverridesPls | [github](https://github.com/qqrz997/KeepMyOverridesPls) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `qqrz997/KeepMyOverridesPls` tag `v1.1.3-b`, asset `KeepMyOverridesPls-1.1.3-bs1.40.6-487d417.zip`; GitHub release digest matched downloaded asset | IPA loaded KeepMyOverridesPls 1.1.3, installed its app installer, wrote config, and the game reached `MainSystemInit`. Warnings: manifest targets Beat Saber 1.40.6 and plugin has no start/exit methods. |
| SoundReplacer | [github](https://github.com/Meivyn/SoundReplacer), [beatmods zip](https://beatmods.com/cdn/mod/7d7a869996e10249d1f85f95e060319b.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 2.0.1, version id 2213, zipHash `7d7a869996e10249d1f85f95e060319b`; GitHub releases API returned no releases on 2026-06-29 | IPA loaded SoundReplacer 2.0.1, installed its app and menu installers, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.29.4. |
| KeyRemapper | [github](https://github.com/lyyQwQ/KeyRemapper) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `lyyQwQ/KeyRemapper` tag `0.3.0`, asset `KeyRemapper-0.3.0-bs1.39.1-8e4c11a.zip`; GitHub release digest matched downloaded asset | IPA loaded KeyRemapper 0.3.0, initialized config, installed menu bindings, registered the Key Remapper button, and the game reached `MainSystemInit`. Warnings: manifest targets Beat Saber 1.39.1 and FPFC smoke logged dummy input manager because runtime was null. |
| SquatToBegin | [github](https://github.com/kinsi55/BeatSaber_SquatToBegin) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `kinsi55/BeatSaber_SquatToBegin` tag `v0.0.7`, asset `SquatToBegin.dll`; GitHub release digest matched downloaded asset | IPA loaded SquatToBegin 0.0.7 and the game reached `MainSystemInit`. Warnings: manifest reports Beat Saber 1.20.0 despite the GitHub release being labeled for 1.39.1+, and plugin has no start/exit methods. Verify squat gate behavior outside FPFC when practical. |
| 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
@@ -200,7 +200,7 @@ Purpose: restore song-list, menu, and visualization conveniences.
| --- | --- | --- | --- | --- |
| BetterSongList | [github](https://github.com/kinsi55/BeatSaber_BetterSongList) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `kinsi55/BeatSaber_BetterSongList` tag `v0.4.3`, asset `BetterSongList.dll`; GitHub release digest matched downloaded asset | IPA loaded BetterSongList 0.4.3 and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.0. |
| HitScoreVisualizer | [github](https://github.com/ErisApps/HitScoreVisualizer) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `ErisApps/HitScoreVisualizer` tag `3.7.3`, asset `HitScoreVisualizer-3.7.3-bs1.42.0-a565cbb.zip`; GitHub release digest matched downloaded asset | IPA loaded HitScoreVisualizer 3.7.3, installed app/menu installers, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.0. |
| DiTails | [github](https://github.com/Auros/DiTails) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | GitHub `Auros/DiTails` tag `1.1.3`, asset `DiTails-v1.1.3-g1.42.0-271d394.zip`; GitHub release digest matched downloaded asset; BeatMods also verifies DiTails 1.1.3 for 1.44.1 as version id 2609, zipHash `437904f6db78a2ee928738d7d254a93f` | IPA loaded DiTails 1.1.3, but menu initialization failed in `DiTails.Managers.DetailContextManager.Initialize()` with `NullReferenceException`. Removed from the live instance after the failed smoke. |
| 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. |
+1 -1
View File
@@ -39,7 +39,7 @@ bs-manager installs these mostly as dependency closure. BeatMods records depende
- Required by: most UI/config mods here, including SongCore, SiraUtil, PlaylistManager, BeatSaverDownloader, Chroma, Vivify, ScoreSaber, and BeatLeader.
- BeatSaberPlaylistsLib https://github.com/Meivyn/BeatSaberPlaylistsLib
- Required by: PlaylistManager.
- BeatSaverSharp https://github.com/lolPants/BeatSaverSharp
- 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.
+18 -18
View File
@@ -163,12 +163,12 @@ reason = "BeatMods verified BeatSaberPlaylistsLib 1.7.2 for Beat Saber 1.44.1 as
[[plugins]]
id = "beatsaversharp"
repo = "lolPants/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. BeatMods upstream URL returned inaccessible via the GitHub releases API, so this remains a BeatMods CDN fallback."
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"
@@ -232,15 +232,6 @@ sha256 = "4108b11eae11d09f8c4e838dde1dc36108f1f3fd4fff31b951e7c551fa59f5f1"
install_strategy = "bsipa-zip"
reason = "BeatMods verified BeatSaverDownloader 6.0.7 for Beat Saber 1.44.1 as version id 2217, zipHash a740c6e68a9b5d1dfda3cc8e81f7cf06. Upstream GitHub exposes no release assets through the releases API, so this remains a BeatMods CDN fallback."
[[plugins]]
id = "playlistmanager"
repo = "rithik-b/PlaylistManager"
tag = "pr-82-localbuild-da1ad17"
asset = "PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip"
sha256 = "57b449e614db1d5214cd3a88000d52f5a989c8521390a9f4819b1c62f20f16fa"
install_strategy = "bsipa-zip"
reason = "Local build from .state/build/playlistmanager-pr82-skilltest, artifact PlaylistManager/bin/Release/net48/zip/PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip. Use this PR82 build instead of the failed BeatMods 1.7.3 compatibility trial for Beat Saber 1.44.1."
[[plugins]]
id = "beatsaverupdater"
repo = "ibillingsley/BeatSaverUpdater"
@@ -289,20 +280,20 @@ reason = "User-provided GitHub repository URL https://github.com/qqrz997/KeepMyO
[[plugins]]
id = "keyremapper"
repo = "lyyQwQ/KeyRemapper"
tag = "0.3.0"
asset = "KeyRemapper-0.3.0-bs1.39.1-8e4c11a.zip"
sha256 = "962e3f6e18bebdf101e6575fa7b8b7e0d92179bab4f0825d122078f1842f7380"
tag = "pr-6-23bb523-1.44-fix"
asset = "KeyRemapper-0.3.0-bs1.44.0-8e4c11a.zip"
sha256 = "b8aaaa72712ae10cec98f51f79b3b87fe7a588b0d3393dd087b7d84975246e6c"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub releases URL https://github.com/lyyQwQ/KeyRemapper/releases. Latest non-draft, non-prerelease release 0.3.0 exposes this asset; GitHub release digest matched the downloaded asset. Release notes label compatibility as Beat Saber 1.39.1 with BSIPA/BSML/SiraUtil dependencies."
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 = "v0.0.7"
tag = "localbuild-8703b84-1.44-fix"
asset = "SquatToBegin.dll"
sha256 = "8426a64f6a3224b8cd79b9ee86347727a5a43c40a0fda116bf9c613f145fc18e"
sha256 = "0bd6b5ee48cae370f255707afb9e452e08a68802f81493dd9a442b6a9f7b98bd"
install_strategy = "dll-to-plugins"
reason = "User-provided GitHub releases URL https://github.com/kinsi55/BeatSaber_SquatToBegin/releases. Latest non-draft, non-prerelease release v0.0.7 is labeled for Beat Saber 1.39.1+ and exposes this direct DLL asset; GitHub release digest matched the downloaded asset."
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"
@@ -340,6 +331,15 @@ 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"
+1 -1
View File
@@ -407,7 +407,7 @@ required = true
[[plugins]]
id = "beatsaversharp"
name = "BeatSaverSharp"
repo = "lolPants/BeatSaverSharp"
repo = "Auros/BeatSaverSharper"
asset_patterns = ["BeatSaverSharp-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
+83 -4
View File
@@ -11,7 +11,7 @@ from .bootstrap import run_bootstrap
from .bsipa import check_bsipa_health
from .checker import check_lock
from .github import fetch_releases
from .installer import apply_plan, uninstall_plugin
from .installer import apply_plan, disable_plugin, uninstall_plugin
from .instances import get_instance, list_instances
from .models import load_lockfile, load_registry
from .models import Lockfile, Registry
@@ -34,7 +34,15 @@ def installed_plugins_report(
) -> dict[str, Any]:
locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins}
plugins: list[dict[str, Any]] = []
for plugin_id, plugin_state in sorted(installed_state.get("plugins", {}).items()):
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", [])
@@ -48,6 +56,8 @@ def installed_plugins_report(
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,
}
@@ -66,14 +76,15 @@ def print_installed_plugins(report: dict[str, Any]) -> None:
print("No plugins have been installed by plugin-helper yet.")
return
headers = ("Plugin", "Version", "Asset", "Files", "Installed")
headers = ("Plugin", "Status", "Version", "Asset", "Files", "Installed")
rows = [
(
f"{plugin['name']} ({plugin['id']})",
plugin["status"],
plugin["version"],
plugin["asset"],
str(plugin["fileCount"]),
plugin["installedAt"],
plugin["disabledAt"] or plugin["installedAt"],
)
for plugin in plugins
]
@@ -236,6 +247,25 @@ def build_parser() -> argparse.ArgumentParser:
uninstall.add_argument("plugin")
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-userdata",
help="Copy UserData and Windows AppData into the adjacent backups repo",
@@ -310,6 +340,8 @@ def _run_menu(inst_roots: list[Path], st_root: Path, input_func: Callable[[str],
("bootstrap-check", "Check BSIPA bootstrap", "Verifies recorded bootstrap state and the latest BSIPA log evidence."),
("plan", "Create install plan", "Writes a dry-run JSON plan for locked plugin files before anything is applied."),
("apply", "Apply a plan by path", "Installs exactly the file changes from a previously generated plan JSON."),
("disable", "Disable managed plugin", "Removes one recorded plugin from the game while keeping helper state and assets."),
("enable", "Enable managed plugin", "Reinstalls one disabled locked plugin from local assets."),
("backup-userdata", "Back up UserData", "Copies UserData and AppData into the adjacent backups repo."),
("restore-userdata", "Restore UserData", "Restores UserData and AppData from the backups repo into an instance."),
("change", "Choose another version", "Returns to the Beat Saber version picker."),
@@ -356,6 +388,12 @@ def _run_menu(inst_roots: list[Path], st_root: Path, input_func: Callable[[str],
print("No plan path entered.")
continue
command.append(plan_path)
elif selected_action in {"disable", "enable"}:
plugin_id = ask("Plugin id> ").strip()
if not plugin_id:
print("No plugin id entered.")
continue
command.extend(["--instance", selected_instance.name, plugin_id])
else:
command.extend(["--instance", selected_instance.name])
@@ -566,6 +604,47 @@ def run(argv: list[str] | None = None) -> int:
print(f" {item['path']}: {item['reason']}")
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)
installed_state = load_installed_state(st_root, args.instance)
if args.plugin not in installed_state.get("disabledPlugins", {}):
raise KeyError(f"plugin is not recorded as disabled: {args.plugin}")
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)
if not any(plugin.id == args.plugin for plugin in lockfile.plugins):
raise KeyError(f"plugin is disabled but not locked for this instance: {args.plugin}")
plan, path = create_plan(
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,
selected={args.plugin},
)
result = apply_plan(plan, st_root)
print(f"Enabled: {args.plugin}")
print(f"Plan: {path}")
print(f"Applied: {len(result['applied'])}")
print(f"State: {result['statePath']}")
return 0
if args.command == "backup-userdata":
instance = get_instance(inst_roots, args.instance)
root = repo_root()
+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,
}
)
installed_state.setdefault("disabledPlugins", {}).pop(change["plugin"], None)
applied.append({"path": rel_target, "plugin": change["plugin"], "backup": backup})
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)
save_installed_state(state_root, instance, installed_state)
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}
+122 -1
View File
@@ -14,7 +14,7 @@ from plugin_helper.beatmods import by_version_id, normalize_mods
from plugin_helper.checker import check_lock
from plugin_helper.cli import installed_plugins_report, run
from plugin_helper.fsutil import sha256_file
from plugin_helper.installer import apply_plan, uninstall_plugin
from plugin_helper.installer import apply_plan, disable_plugin, uninstall_plugin
from plugin_helper.instances import get_instance, list_instances
from plugin_helper.models import Lockfile, LockedPlugin, Registry, RegistryPlugin
from plugin_helper.planner import create_plan
@@ -242,6 +242,127 @@ class PluginHelperTests(unittest.TestCase):
self.assertEqual(removed["removed"], ["Plugins/Example.dll"])
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
def test_disable_plugin_removes_files_but_keeps_disabled_state(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.40.8"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
(instance / "Plugins").mkdir()
target = instance / "Plugins" / "Example.dll"
target.write_bytes(b"managed dll")
installed = {
"instance": "1.40.8",
"plugins": {
"example": {
"installedAt": "2026-06-14T17:18:40Z",
"files": [
{
"path": "Plugins/Example.dll",
"sha256": sha256_file(target),
"size": target.stat().st_size,
}
],
}
},
}
from plugin_helper.state import save_installed_state
save_installed_state(state, "1.40.8", installed)
result = disable_plugin("1.40.8", instance, state, "example")
self.assertEqual(result["removed"], ["Plugins/Example.dll"])
self.assertFalse(target.exists())
updated = load_installed_state(state, "1.40.8")
self.assertNotIn("example", updated["plugins"])
self.assertIn("example", updated["disabledPlugins"])
self.assertEqual(updated["disabledPlugins"]["example"]["files"][0]["path"], "Plugins/Example.dll")
def test_enable_command_reinstalls_disabled_plugin_from_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance_root = work / "instances"
instance = instance_root / "1.40.8"
state = work / "state"
registry_dir = work / "registry"
locks_dir = work / "locks"
registry_dir.mkdir()
locks_dir.mkdir()
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
(instance / "Plugins").mkdir()
asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll"
asset.write_bytes(b"managed dll")
(registry_dir / "plugins.toml").write_text(
"""
[[plugins]]
id = "example"
name = "Example"
repo = "owner/example"
asset_patterns = ["*.dll"]
install_strategy = "dll-to-plugins"
""".lstrip(),
encoding="utf-8",
)
(locks_dir / "1.40.8.lock.toml").write_text(
f"""
beat_saber_version = "1.40.8"
instance = "1.40.8"
[[plugins]]
id = "example"
repo = "owner/example"
tag = "v1.0.0"
asset = "Example.dll"
sha256 = "{sha256_file(asset)}"
""".lstrip(),
encoding="utf-8",
)
disabled = {
"instance": "1.40.8",
"plugins": {},
"disabledPlugins": {
"example": {
"installedAt": "2026-06-14T17:18:40Z",
"disabledAt": "2026-06-14T17:20:00Z",
"files": [
{
"path": "Plugins/Example.dll",
"sha256": sha256_file(asset),
"size": asset.stat().st_size,
}
],
}
},
}
from plugin_helper.state import save_installed_state
save_installed_state(state, "1.40.8", disabled)
with patch("plugin_helper.cli.repo_root", return_value=work):
status = run(
[
"--instances-root",
str(instance_root),
"--state-dir",
str(state),
"enable",
"--instance",
"1.40.8",
"example",
]
)
self.assertEqual(status, 0)
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
updated = load_installed_state(state, "1.40.8")
self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated["disabledPlugins"])
def test_zip_to_pending_targets_ipa_pending(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)