Compare commits

...

10 Commits

Author SHA1 Message Date
pleb 54803c5c5b Update NalulunaAvatars Patreon note link 2026-07-13 09:26:13 -07:00
pleb f0d7955ea0 Add reesabers 2026-07-12 14:42:54 -07:00
pleb 17d50e1239 Add / filter for the plugin menu list 2026-07-12 14:42:46 -07:00
pleb 4b544e0858 Refresh Naluluna bundle for Utils 5.8.0 from ModAssistant 2026-07-11 16:32:34 -07:00
pleb c7d629644a Add accsaber and don't update menu after reloading 2026-07-11 16:25:20 -07:00
pleb f40f3ddc56 Fix and add cinema on windows 2026-07-11 16:20:58 -07:00
pleb bcfada09f9 Make menu clickable 2026-07-11 16:02:38 -07:00
pleb 861ce587f3 Add start script for Windows 2026-07-11 15:26:20 -07:00
pleb 77a15daeda Merge branch 'main' of gitea.satstack.dev:pleb/beatsaber-plugin-helper 2026-07-11 15:14:17 -07:00
pleb 8ff20bd4e4 Sort plugin menu by name and document Windows build prerequisites
List installed plugins alphabetically in the menu and installed report. Add a short README note on installing the .NET Framework 4.8 Developer Pack via winget.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 18:03:29 -07:00
11 changed files with 431 additions and 54 deletions
+19
View File
@@ -98,6 +98,11 @@ that checkout's `.state/` directory. If `plugin-helper.windows.toml` has only
one `[[profiles]]` entry, that profile is selected automatically when
`--profile` is omitted:
From the repo root, double-click or run `menu.bat` to create `.venv` on first
use, install the package, and open the Textual menu.
Or set up manually:
```powershell
py -m venv .venv
.\.venv\Scripts\Activate.ps1
@@ -114,6 +119,20 @@ completed successfully.
When no config file is present on Windows, defaults are
`~/BSManager/BSInstances` and `%LOCALAPPDATA%/plugin-helper`.
## Building plugins
PC BSIPA plugins target .NET Framework (`net48`). On native Windows, install the
reference assemblies before `dotnet build`:
```powershell
winget install -e --id Microsoft.DotNet.Framework.DeveloperPack_4
```
Point `BeatSaberDir` at your BSManager instance and pass
`-p:DisableCopyToPlugins=True` so builds do not mutate the live install. See
[`.agents/skills/beatsaber-plugin-builder/`](.agents/skills/beatsaber-plugin-builder/)
for the full workflow.
## Commands
For normal use, run the Textual menu from the repo root:
@@ -111,16 +111,6 @@ stack loaded successfully.
| SquatToBegin lifecycle notice | `[IPA/Loader] Plugin SquatToBegin has no methods marked [OnStart] or [OnEnable]` | Upstream plugin shape; gate still arms from gameplay scene setup. |
| IPA feature deprecation | `[IPA/Loader/Features] Encountered old features used` / `No such feature SiraLocalizer.LocalizedPlugin` | Loader metadata drift; not a load failure. |
Still treat these as real problems when investigating a failed smoketest:
- `Mono.Cecil.AssemblyResolutionException`, `TypeLoadException`, or missing
assembly errors for an installed plugin
- Harmony `Patching exception` or repeated `NullReferenceException` stacks from
a newly installed plugin (LeaderboardCore `PanelView_SetIsLoaded` is a known
pre-existing warning on this stack; revisit only if in-game leaderboards break)
- Plugin absent from the IPA load list after install
- Startup never reaches `[MainSystemInit] Initialization was completed`
## Status Legend
- <span style="color:#8b949e; font-weight:600">todo</span>: not attempted
@@ -236,7 +226,7 @@ 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. |
| 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. SongDetailsCache is optional but recommended for cached song-detail lookups; the registry lists it as a non-required dependency. |
| 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. |
@@ -267,7 +257,7 @@ treat its output as local/private payload input for `plugin-helper`.
| 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. |
| ReeSabers | [beatleader patreon](https://www.patreon.com/beatleader) | <span style="color:#d29922; font-weight:600">installed; smoke pending</span> | User-provided Patreon zip `ReeSabers_1.42.0.zip` (original SHA-256 `258b28b27e8a9274a15b22c91ed9a611a85367d9a9f31b575fd992ecd4bdc3ee`); staged helper payload SHA-256 `3f4b08fc450b1dc29f4fc3282c5099d3e09578e3fa2734dfa3c5d670dddfb219` omits bundled `CameraUtils.dll` to preserve locked CameraUtils 1.0.8 | Helper installed `Plugins/ReeSabers.dll` and `UserData/ReeSabers/` presets/textures on 2026-07-12. Live smoke not run yet; verify saber visuals in VR when practical. |
| BeatSaberPlus_Chat | [discord](https://discord.com/channels/723117082111246416/768683248804167690) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Discord release zip `BeatSaberPlus_v6.4.5-bs-1.44.0.zip`, SHA-256 `21ad51edea5703485779b851e7e86aac11c3d6e3b1de2460b2c3c3b4b5704657`; manual install into Windows BSManager `1.44.1` root `Plugins/` | IPA loaded BeatSaberPlus_Chat 6.4.5, ChatPlexSDK initialized all modules and added a menu button, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.44.0. Chat/Twitch module UI not exercised in FPFC smoke. |
| BeatSaberPlus_ChatEmoteRain | [discord](https://discord.com/channels/723117082111246416/768683248804167690) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Same Discord bundle as BeatSaberPlus_Chat | IPA loaded BeatSaberPlus_ChatEmoteRain 6.4.5 and ChatPlexSDK initialized the Chat Emote Rain module; same FPFC smoketest as BeatSaberPlus_Chat on 2026-06-29. |
| BeatSaberPlus_ChatIntegrations | [discord](https://discord.com/channels/723117082111246416/768683248804167690) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Same Discord bundle as BeatSaberPlus_Chat | IPA loaded BeatSaberPlus_ChatIntegrations 6.4.5 and ChatPlexSDK initialized the Chat Integrations module; same FPFC smoketest as BeatSaberPlus_Chat on 2026-06-29. |
@@ -288,7 +278,7 @@ treat its output as local/private payload input for `plugin-helper`.
| NalulunaFps | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Not present in Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29. Verify overlay after install. |
| NalulunaPPCoin | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Not present in Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29. Verify startup after install. |
| NalulunaRewinder | [patreon](https://www.patreon.com/c/naluluna/posts) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Patreon via NalulunaModAssistant; `NalulunaRewinder` 1.2.0; manual install into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded NalulunaRewinder 1.2.0, config loaded, menu button registered, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.2. Rewind behavior not exercised in FPFC smoke. |
| NalulunaAvatars | [patreon](https://www.patreon.com/c/naluluna/posts) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Patreon via NalulunaModAssistant; `NalulunaAvatars` 1.10.0 plus bundled `AliciaSolid` 1.10.0 manifest; manual install into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded NalulunaAvatars 1.10.0, logged `Init`, started OSC face tracking, and registered its menu button; the game reached `MainSystemInit`. Warnings: configured avatar `2299336039803240.vrm` missing; IPA logged missing `NalulunaAvatars.resources`; one `NullReferenceException` in `HMUI.CurvedCanvasSettings.Start` during menu setup. VR avatar load not exercised. |
| NalulunaAvatars | [patreon](https://www.patreon.com/naluluna/posts/nalulunaavatars-52013165) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Patreon via NalulunaModAssistant; `NalulunaAvatars` 1.10.0 plus bundled `AliciaSolid` 1.10.0 manifest; manual install into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded NalulunaAvatars 1.10.0, logged `Init`, started OSC face tracking, and registered its menu button; the game reached `MainSystemInit`. Warnings: configured avatar `2299336039803240.vrm` missing; IPA logged missing `NalulunaAvatars.resources`; one `NullReferenceException` in `HMUI.CurvedCanvasSettings.Start` during menu setup. VR avatar load not exercised. |
| NalulunaShaders | [patreon](https://www.patreon.com/c/naluluna/posts) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Patreon via NalulunaModAssistant; `NalulunaShaders` 2.1.3 archive `NalulunaShaders-2.1.3-bs1.39.1.zip`; manual install into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded NalulunaShaders 2.1.3, logged `Assets loaded`, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.39.1. |
| NalulunaSkybox | [patreon](https://www.patreon.com/c/naluluna/posts) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Patreon via NalulunaModAssistant; `NalulunaSkybox` 2.0.0 plus `NalulunaSkyboxSamples` 1.1.0 manifest; manual install into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded NalulunaSkybox 2.0.0 and NalulunaSkyboxSamples 1.1.0, controller `Awake`, menu button registration, and the game reached `MainSystemInit`. Warning: Skybox manifest targets Beat Saber 1.39.1. Skybox swap not exercised in FPFC smoke. |
| NalulunaUtils | [patreon](https://www.patreon.com/c/naluluna/posts) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Patreon via NalulunaModAssistant; `NalulunaUtils` 5.7.0 archive `NalulunaUtils-5.7.0-bs1.44.0.zip` plus bundled `OpenVRHelper` 0.2.3 manifest; manual install into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded NalulunaUtils 5.7.0 and OpenVRHelper 0.2.3, logged `Awake`, registered dependent Naluluna menu buttons, and `OnMenuSceneActive: Succeeded`; the game reached `MainSystemInit`. Warnings: manifest targets Beat Saber 1.44.0; IPA logged missing `NalulunaUtils.resources` and optional libs (SteamVR, DynamicBone, FinalIK, CameraPlus, BGNet). |
+43 -15
View File
@@ -44,12 +44,12 @@ reason = "BeatMods verified SongCore 3.16.0 for Beat Saber 1.44.1 as version id
[[plugins]]
id = "customjsondata"
repo = "Aeroluna/CustomJSONData"
tag = "v2.6.8"
asset = "CustomJSONData-2.6.8+1.40.0-bs1.40.0-7c2c32c.zip"
download_url = "https://github.com/Aeroluna/CustomJSONData/releases/download/v2.6.8/CustomJSONData-2.6.8%2B1.40.0-bs1.40.0-7c2c32c.zip"
tag = "beatmods-2.6.8+1.40.0"
asset = "CustomJSONData-2.6.8+1.40.0.zip"
download_url = "https://beatmods.com/cdn/mod/fed31638bbb678580ef760ec83cfd486.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."
reason = "BeatMods verified CustomJSONData 2.6.8+1.40.0 for Beat Saber 1.44.1 as version id 2327, zipHash fed31638bbb678580ef760ec83cfd486. Downloaded from BeatMods CDN and verified SHA-256 30555c77485d2837bd294608fe4e23c34415b89413997be67a6150318090b216."
[[plugins]]
id = "heck"
@@ -201,12 +201,12 @@ reason = "BeatMods verified ScoreSaberSharp 0.1.0 for Beat Saber 1.44.1 as versi
[[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"
download_url = "https://github.com/ScoreSaber/pc-mod/releases/download/v3.3.27/ScoreSaber-v3.3.27-bs1.42.0-to-1.44.0-9b4cfcf.zip"
sha256 = "8ad509ab38353dfb4d92d127ba4e40917552f700580a7384509cafa70e62c096"
tag = "v3.4.1"
asset = "ScoreSaber-v3.4.1-bs1.42.0-to-1.44.1-4a3f9ff.zip"
download_url = "https://github.com/ScoreSaber/pc-mod/releases/download/v3.4.1/ScoreSaber-v3.4.1-bs1.42.0-to-1.44.1-4a3f9ff.zip"
sha256 = "6a74de62103b9b6afb1e02a3740d800cf696a88beee68e55d56de24751d5f8de"
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."
reason = "Updated from GitHub ScoreSaber/pc-mod tag v3.4.1 release https://github.com/ScoreSaber/pc-mod/releases/tag/v3.4.1, asset ScoreSaber-v3.4.1-bs1.42.0-to-1.44.1-4a3f9ff.zip. GitHub release digest matched 6a74de62103b9b6afb1e02a3740d800cf696a88beee68e55d56de24751d5f8de."
[[plugins]]
id = "protobuf-net"
@@ -248,6 +248,16 @@ 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 = "accsaber-reloaded"
repo = "not-dexter/accsaber-reloaded-plugin"
tag = "v1.1.3"
asset = "1.40.8.zip"
download_url = "https://github.com/not-dexter/accsaber-reloaded-plugin/releases/download/v1.1.3/1.40.8.zip"
sha256 = "c1b1687e8378ee7f550edcbd0811fa71f8ddbaa9f167a5d1b48d4cbf9e808af7"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub releases URL https://github.com/not-dexter/accsaber-reloaded-plugin/releases. Latest release v1.1.3 has no 1.44.x asset; use highest available game-version asset 1.40.8.zip. GitHub release digest matched the downloaded asset. Depends on BSIPA, BeatSaberMarkupLanguage, SiraUtil, SongCore, and LeaderboardCore (satisfied by BeatLeader-bundled LeaderboardCore)."
[[plugins]]
id = "beatsaverdownloader"
repo = "Top-Cat/BeatSaverDownloader"
@@ -268,6 +278,15 @@ 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 = "playlistmanager"
repo = "rithik-b/PlaylistManager"
tag = "localbuild-pr82-da1ad17-1.44"
asset = "PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip"
sha256 = "cb86029a2defca846e2e1ebdf75bbe1368797fbf1b8ce509615155080b9ffbf9"
install_strategy = "bsipa-zip"
reason = "Local build from GitHub PR https://github.com/rithik-b/PlaylistManager/pull/82 at commit da1ad17. Use this PR build instead of upstream releases, which still target older Beat Saber versions and fail on 1.44.x with IPlatformUserModel / PlatformUserModel resolution errors."
[[plugins]]
id = "failbutton"
repo = "qe201020335/FailButton"
@@ -291,11 +310,11 @@ reason = "User-provided GitHub repository URL https://github.com/Reezonate/EasyO
[[plugins]]
id = "cinema"
repo = "MisterKelley/BeatSaberCinema"
tag = "localbuild-010ce6e-bs-1.42.3-bsl-filter"
tag = "localbuild-010ce6e-bs-1.42.3"
asset = "BeatSaberCinema-1.7.13-bs1.42.3-010ce6e.zip"
sha256 = "2294f3a4e082626ae4508f821469e8f5156c827bf37efdf3327fa6e12039d5e0"
sha256 = "f31d186444908df121b8b1e7491e557a6acfc501ba9a92f06989e7dd31444fad"
install_strategy = "bsipa-zip"
reason = "User-provided fork/branch URL https://github.com/MisterKelley/BeatSaberCinema/tree/bs-1.42.3. Built locally from commit 010ce6e01728af1e21774ccbb8d14405773f5b02 against the 1.44.1 instance after fixing the BGLib.AppFlow hint-path casing for Linux and compiling HasVideoFilter against BetterSongList 0.4.3's real ITransformerPlugin/IFilter interfaces; manifest still targets Beat Saber 1.42.3."
reason = "User-provided fork/branch URL https://github.com/MisterKelley/BeatSaberCinema/tree/bs-1.42.3. Rebuilt locally on Windows from commit 010ce6e01728af1e21774ccbb8d14405773f5b02 against the 1.44.1 instance (Release DLL sha256 ef23c5ffff6d93aaf00cb1f5661873e945b3e59fc5d475c5616f6ce6a09148ae packaged as Plugins/BeatSaberCinema.dll). Manifest still targets Beat Saber 1.42.3; HasVideoFilter uses reflection against BetterSongList."
[[plugins]]
id = "cinema-tools"
@@ -473,8 +492,17 @@ reason = "User-provided Discord release zip C:\\Users\\pleb\\Downloads\\BeatSabe
[[plugins]]
id = "naluluna"
repo = "patreon/NalulunaModAssistant"
tag = "installed-1.44.1"
tag = "installed-1.44.1-utils-5.8.0"
asset = "Naluluna-1.44.1-installed-bundle.zip"
sha256 = "40c8998257aa95ab58aab3e40802eabbf1c7ef83d6c79114a93631e508d9d38b"
sha256 = "1d05575a1dd5c0d95aa001384c9f5a2b854361b937cefac13891b1a586c3f2ed"
install_strategy = "root-zip"
reason = "Local bundle captured from the Windows BSManager 1.44.1 instance on 2026-07-01 after NalulunaModAssistant Patreon install. Includes Plugins payloads plus UserData/NalulunaUtils, UserData/NalulunaSkybox, NalulunaAvatars, and Libs/Native/OVRLipSync.dll."
reason = "Local bundle recaptured from the Windows BSManager 1.44.1 instance on 2026-07-11 after NalulunaModAssistant installed NalulunaUtils 5.8.0 (archive NalulunaUtils-5.8.0-bs1.40.8.zip). Only Plugins/NalulunaUtils.dll changed versus the prior 2026-07-01 bundle; other Naluluna payloads, OpenVRHelper, skybox samples, AliciaSolid, and Libs/Native/OVRLipSync.dll are unchanged."
[[plugins]]
id = "reesabers"
repo = "patreon/ReeSabers"
tag = "1.42.0"
asset = "ReeSabers_1.42.0.zip"
sha256 = "3f4b08fc450b1dc29f4fc3282c5099d3e09578e3fa2734dfa3c5d670dddfb219"
install_strategy = "root-zip"
reason = "User-provided BeatLeader Patreon zip C:\\Users\\pleb\\Downloads\\ReeSabers_1.42.0.zip (original SHA-256 258b28b27e8a9274a15b22c91ed9a611a85367d9a9f31b575fd992ecd4bdc3ee). Staged install payload omits bundled Plugins/CameraUtils.dll so it does not downgrade the locked CameraUtils 1.0.8 already present on 1.44.1; installs Plugins/ReeSabers.dll plus UserData/ReeSabers presets and textures."
+38
View File
@@ -0,0 +1,38 @@
@echo off
setlocal
cd /d "%~dp0"
set "VENV_PY=.venv\Scripts\python.exe"
set "STAMP_FILE=.venv\.last-install"
set "NEED_INSTALL=0"
if not exist "%VENV_PY%" (
echo Creating virtual environment...
py -m venv .venv
if errorlevel 1 (
echo Failed to create virtual environment.
exit /b 1
)
set "NEED_INSTALL=1"
) else if not exist "%STAMP_FILE%" (
set "NEED_INSTALL=1"
) else (
"%VENV_PY%" -c "import pathlib, sys, time; p=pathlib.Path(r'%STAMP_FILE%'); sys.exit(0 if time.time()-p.stat().st_mtime >= 20*3600 else 1)"
if not errorlevel 1 set "NEED_INSTALL=1"
)
if "%NEED_INSTALL%"=="1" (
echo Installing plugin-helper...
"%VENV_PY%" -m pip install -e .
if errorlevel 1 (
echo Failed to install plugin-helper.
exit /b 1
)
type nul > "%STAMP_FILE%"
)
"%VENV_PY%" -m plugin_helper
set EXIT_CODE=%ERRORLEVEL%
endlocal & exit /b %EXIT_CODE%
+8 -1
View File
@@ -1,6 +1,13 @@
id = "accsaber-reloaded"
name = "AccSaber Reloaded"
repo = "not-dexter/accsaber-reloaded-plugin"
asset_patterns = ["1.40.8.zip"]
asset_patterns = ["1.*.zip"]
install_strategy = "bsipa-zip"
category = "leaderboard"
dependencies = [
{ id = "bsipa", constraint = ">=4.2.2" },
{ id = "beatsabermarkuplanguage", constraint = ">=1.6.10" },
{ id = "sirautil", constraint = ">=3.1.2" },
{ id = "songcore", constraint = ">=3.10.2" },
{ id = "beatleader", constraint = ">=0.10.0" },
]
+3
View File
@@ -4,3 +4,6 @@ repo = "kinsi55/BeatSaber_BetterSongList"
asset_patterns = ["BetterSongList.dll"]
install_strategy = "dll-to-plugins"
category = "ui"
dependencies = [
{ id = "songdetailscache", constraint = ">=1.4.0", required = false },
]
+11
View File
@@ -0,0 +1,11 @@
id = "reesabers"
name = "ReeSabers"
repo = "patreon/ReeSabers"
asset_patterns = ["ReeSabers_*.zip"]
install_strategy = "root-zip"
category = "cosmetic"
dependencies = [
{ id = "camerautils", constraint = ">=1.0.8" },
{ id = "beatleader", constraint = ">=0.10.0" },
{ id = "bsipa", constraint = ">=4.3.7" },
]
@@ -89,6 +89,7 @@ def _read_ffmpeg_exe(archive_path: Path) -> bytes:
def _write_member(archive: ZipFile, name: str, data: bytes) -> None:
info = ZipInfo(name, date_time=ZIP_TIMESTAMP)
info.compress_type = ZIP_STORED
info.create_system = 3 # Unix; keep the bundle hash stable across hosts
info.external_attr = 0o644 << 16
archive.writestr(info, data)
+1
View File
@@ -50,6 +50,7 @@ def installed_plugins_report(
"files": files,
}
)
plugins.sort(key=lambda plugin: (plugin["name"].casefold(), plugin["id"]))
return {
"instance": installed_state.get("instance", lockfile.instance),
"beatSaberVersion": installed_state.get("beatSaberVersion", lockfile.beat_saber_version),
+214 -18
View File
@@ -6,9 +6,11 @@ from pathlib import Path
from typing import Any
from rich.text import Text
from textual import events, on
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import DataTable, Footer, Header, Static
from textual.coordinate import Coordinate
from textual.widgets import DataTable, Footer, Header, Input, Static
from .bsipa import BSIPA_PLUGIN_ID
from .installer import disable_plugin
@@ -32,6 +34,43 @@ class InstallationChoice:
state_root: Path
class ActivatableDataTable(DataTable):
"""DataTable that activates a row on every click, not only a re-click."""
async def _on_click(self, event: events.Click) -> None:
# Textual dispatches `_on_click` for every class in the MRO, so we fully
# handle the click here and prevent DataTable's handler from also running.
self._set_hover_cursor(True)
meta = event.style.meta
if "row" not in meta or "column" not in meta:
event.prevent_default()
return
if self.cursor_type != "row" and meta.get("out_of_bounds", False):
event.prevent_default()
return
row_index = meta["row"]
column_index = meta["column"]
is_header_click = self.show_header and row_index == -1
is_row_label_click = self.show_row_labels and column_index == -1
if is_header_click:
column = self.ordered_columns[column_index]
self.post_message(
DataTable.HeaderSelected(self, column.key, column_index, label=column.label)
)
elif is_row_label_click:
row = self.ordered_rows[row_index]
self.post_message(
DataTable.RowLabelSelected(self, row.key, row_index, label=row.label)
)
elif self.show_cursor and self.cursor_type != "none":
self.cursor_coordinate = Coordinate(row_index, column_index)
self._post_selected_message()
self._scroll_cursor_into_view(animate=True)
event.stop()
event.prevent_default()
class PluginHelperTui(App[int]):
CSS = """
#title {
@@ -39,6 +78,14 @@ class PluginHelperTui(App[int]):
text-style: bold;
}
#filter {
margin: 0 1;
}
#filter.-hidden {
display: none;
}
#status {
padding: 0 1;
color: $text-muted;
@@ -47,6 +94,8 @@ class PluginHelperTui(App[int]):
BINDINGS = [
Binding("enter", "select", "Select", priority=True),
Binding("space", "toggle_plugin", "Toggle", priority=True),
Binding("/", "start_filter", "Filter", priority=True),
Binding("escape", "clear_filter", "Clear filter", show=False, priority=True),
Binding("d", "disable_all_plugins", "Disable all", priority=True),
Binding("e", "enable_all_plugins", "Enable all", priority=True),
Binding("s", "save_known_good", "Save known-good", priority=True),
@@ -71,25 +120,55 @@ class PluginHelperTui(App[int]):
self.mode = "installations"
self.selected_installation: InstallationChoice | None = None
self.plugin_rows: list[dict[str, Any]] = []
self.filter_query = ""
self.status_message = ""
self._busy = False
def compose(self) -> ComposeResult:
yield Header(show_clock=False)
yield Static("", id="title")
yield DataTable(id="table")
filter_input = Input(placeholder="Filter plugins by name or id...", id="filter", classes="-hidden")
filter_input.can_focus = False
yield filter_input
yield ActivatableDataTable(id="table")
yield Static("", id="status")
yield Footer()
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
if self._filter_input_focused():
if action in {
"select",
"toggle_plugin",
"start_filter",
"disable_all_plugins",
"enable_all_plugins",
"save_known_good",
"restore_known_good",
"back",
}:
return False
elif action == "clear_filter" and not self.filter_query and not self._filter_visible():
return False
return True
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.cursor_type = "row"
filter_input = self.query_one("#filter", Input)
filter_input.can_focus = False
if len(self.choices) == 1:
self.selected_installation = self.choices[0]
self._show_plugins()
return
self._show_installations()
async def on_data_table_row_selected(self, _event: DataTable.RowSelected) -> None:
if self.mode == "installations":
self.action_select()
return
if self.mode == "plugins":
await self.action_toggle_plugin()
def action_select(self) -> None:
if self.mode != "installations":
return
@@ -103,6 +182,7 @@ class PluginHelperTui(App[int]):
if self.mode == "plugins":
if len(self.choices) == 1:
return
self._clear_filter_state()
self._show_installations()
def action_refresh(self) -> None:
@@ -113,13 +193,63 @@ class PluginHelperTui(App[int]):
else:
self._show_installations()
def action_start_filter(self) -> None:
if self.mode != "plugins" or self._busy:
return
filter_input = self.query_one("#filter", Input)
self._set_filter_visible(True)
filter_input.can_focus = True
filter_input.focus()
self._set_status("Type to filter. Enter keeps filter. Esc clears.")
def action_clear_filter(self) -> None:
if self.mode != "plugins":
return
had_query = bool(self.filter_query)
was_focused = self._filter_input_focused()
if not had_query and not was_focused and not self._filter_visible():
return
self._clear_filter_state()
self.query_one(DataTable).focus()
if had_query:
self._render_plugin_rows()
self._set_status("Filter cleared.")
else:
self._set_plugin_status_hint()
@on(Input.Changed, "#filter")
def _on_filter_changed(self, event: Input.Changed) -> None:
if self.mode != "plugins":
return
self.filter_query = event.value
self._render_plugin_rows()
filtered = self._filtered_plugins()
if self.filter_query.strip():
self._set_status(f"Filter: {len(filtered)}/{len(self.plugin_rows)} plugins.")
else:
self._set_status("Type to filter. Enter keeps filter. Esc clears.")
@on(Input.Submitted, "#filter")
def _on_filter_submitted(self, _event: Input.Submitted) -> None:
if self.mode != "plugins":
return
if not self.filter_query.strip():
self._set_filter_visible(False)
self.query_one(DataTable).focus()
filtered = self._filtered_plugins()
if self.filter_query.strip():
self._set_status(f"Filtered to {len(filtered)}/{len(self.plugin_rows)} plugins. / edits filter.")
else:
self._set_plugin_status_hint()
async def action_toggle_plugin(self) -> None:
if self._busy or self.mode != "plugins" or self.selected_installation is None:
return
index = self._cursor_index(len(self.plugin_rows))
rows = self._filtered_plugins()
index = self._cursor_index(len(rows))
if index is None:
return
plugin = self.plugin_rows[index]
plugin = rows[index]
plugin_id = plugin["id"]
target = self.selected_installation
self._busy = True
@@ -298,6 +428,7 @@ class PluginHelperTui(App[int]):
def _show_installations(self) -> None:
self.mode = "installations"
self.plugin_rows = []
self._clear_filter_state()
self._set_title("Choose Beat Saber Installation")
table = self.query_one(DataTable)
table.clear(columns=True)
@@ -312,16 +443,19 @@ class PluginHelperTui(App[int]):
if self.setup_hint:
self._set_status(self.setup_hint)
else:
self._set_status("Enter selects an installation. q quits.")
self._set_status("Click or 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
selected_row: int | None = None
selected_id: str | None = None
if self.mode == "plugins" and self.plugin_rows:
selected_row = self._cursor_index(len(self.plugin_rows))
filtered = self._filtered_plugins()
selected_row = self._cursor_index(len(filtered))
if selected_row is not None:
selected_id = filtered[selected_row]["id"]
self.mode = "plugins"
self._set_title(f"{target.install_label} / {target.instance_name}")
table = self.query_one(DataTable)
@@ -342,7 +476,22 @@ class PluginHelperTui(App[int]):
self._set_status(f"Could not load plugins: {exc}")
return
for plugin in self.plugin_rows:
self._render_plugin_rows(selected_id=selected_id)
if not preserve_status:
if self.plugin_rows:
self._set_plugin_status_hint()
else:
self._set_status("No version-locked plugins for this installation.")
def _render_plugin_rows(self, *, selected_id: str | None = None) -> None:
table = self.query_one(DataTable)
if selected_id is None and self.mode == "plugins" and table.row_count:
selected_row = self._cursor_index(table.row_count)
if selected_row is not None:
selected_id = str(table.get_row_at(selected_row)[2])
table.clear()
filtered = self._filtered_plugins()
for plugin in filtered:
table.add_row(
self._status_marker(plugin["status"]),
plugin["name"],
@@ -351,17 +500,64 @@ class PluginHelperTui(App[int]):
str(plugin["fileCount"]),
plugin["asset"],
)
if selected_row is not None and self.plugin_rows:
table.move_cursor(row=min(selected_row, len(self.plugin_rows) - 1))
if not preserve_status:
if self.plugin_rows:
back_hint = "" if len(self.choices) == 1 else " b returns to installations."
self._set_status(
"Space toggles selected. d disables all. e enables all. "
f"s saves known-good. g restores known-good.{back_hint}"
)
if selected_id is not None and filtered:
for index, plugin in enumerate(filtered):
if plugin["id"] == selected_id:
table.move_cursor(row=index)
break
else:
self._set_status("No version-locked plugins for this installation.")
table.move_cursor(row=0)
elif filtered:
table.move_cursor(row=0)
def _filtered_plugins(self) -> list[dict[str, Any]]:
query = self.filter_query.strip().lower()
if not query:
return list(self.plugin_rows)
return [
plugin
for plugin in self.plugin_rows
if query in str(plugin.get("name", "")).lower()
or query in str(plugin.get("id", "")).lower()
or query in str(plugin.get("asset", "")).lower()
]
def _clear_filter_state(self) -> None:
self.filter_query = ""
try:
filter_input = self.query_one("#filter", Input)
except Exception:
return
if filter_input.value:
with filter_input.prevent(Input.Changed):
filter_input.value = ""
filter_input.can_focus = False
self._set_filter_visible(False)
def _set_filter_visible(self, visible: bool) -> None:
filter_input = self.query_one("#filter", Input)
filter_input.set_class(not visible, "-hidden")
def _filter_visible(self) -> bool:
try:
return not self.query_one("#filter", Input).has_class("-hidden")
except Exception:
return False
def _filter_input_focused(self) -> bool:
focused = self.focused
return focused is not None and getattr(focused, "id", None) == "filter"
def _set_plugin_status_hint(self) -> None:
if not self.plugin_rows:
self._set_status("No version-locked plugins for this installation.")
return
back_hint = "" if len(self.choices) == 1 else " b returns to installations."
filter_hint = f" / filters ({len(self._filtered_plugins())}/{len(self.plugin_rows)})." if self.filter_query.strip() else " / filters."
self._set_status(
"Click or Space toggles selected. d disables all. e enables all. "
f"s saves known-good. g restores known-good.{filter_hint}{back_hint}"
)
def _cursor_index(self, row_count: int) -> int | None:
table = self.query_one(DataTable)
+90 -7
View File
@@ -11,7 +11,7 @@ from zipfile import ZipFile
from rich.text import Text
from textual.coordinate import Coordinate
from textual.widgets import DataTable
from textual.widgets import DataTable, Input
from plugin_helper.bootstrap import _run_ipa, build_bootstrap_command, ensure_healthy_bootstrap
from plugin_helper.bsipa import check_bsipa_health, planning_requires_bootstrap
@@ -1707,17 +1707,19 @@ instance = "1.40.8"
self.assertEqual(report["plugins"][0]["asset"], "Example.dll")
self.assertEqual(report["plugins"][0]["fileCount"], 1)
def test_installed_plugins_report_starts_from_version_lock(self) -> None:
def test_installed_plugins_report_sorts_by_name(self) -> None:
registry = Registry(
{
"alpha": RegistryPlugin(id="alpha", name="Alpha", repo="owner/alpha"),
"beta": RegistryPlugin(id="beta", name="Beta", repo="owner/beta"),
"zebra": RegistryPlugin(id="zebra", name="Zebra", repo="owner/zebra"),
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(id="zebra", repo="owner/zebra", tag="v3.0.0", asset="Zebra.dll", sha256="z"),
LockedPlugin(id="alpha", repo="owner/alpha", tag="v1.0.0", asset="Alpha.dll", sha256="a"),
LockedPlugin(id="beta", repo="owner/beta", tag="v2.0.0", asset="Beta.dll", sha256="b"),
),
@@ -1729,9 +1731,9 @@ instance = "1.40.8"
lockfile=lockfile,
)
self.assertEqual([plugin["id"] for plugin in report["plugins"]], ["alpha", "beta"])
self.assertEqual([plugin["status"] for plugin in report["plugins"]], ["disabled", "disabled"])
self.assertEqual([plugin["fileCount"] for plugin in report["plugins"]], [0, 0])
self.assertEqual([plugin["id"] for plugin in report["plugins"]], ["alpha", "beta", "zebra"])
self.assertEqual([plugin["status"] for plugin in report["plugins"]], ["disabled", "disabled", "disabled"])
self.assertEqual([plugin["fileCount"] for plugin in report["plugins"]], [0, 0, 0])
self.assertEqual(report["plugins"][1]["version"], "v2.0.0")
def test_update_check_reports_current_matching_asset(self) -> None:
@@ -2604,8 +2606,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
table = app.query_one(DataTable)
self.assertEqual(table.row_count, 2)
self.assertEqual(app.mode, "installations")
self.assertEqual(str(table.get_cell_at(Coordinate(0, 3))), "/tmp/state-linux/installs/linux")
self.assertEqual(str(table.get_cell_at(Coordinate(1, 3))), "/tmp/state-windows/installs/windows")
self.assertEqual(table.get_cell_at(Coordinate(0, 3)), str(Path("/tmp/state-linux/installs/linux")))
self.assertEqual(table.get_cell_at(Coordinate(1, 3)), str(Path("/tmp/state-windows/installs/windows")))
async def test_single_instance_skips_installation_picker(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
@@ -2634,6 +2636,43 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("example", updated["plugins"])
self.assertIn("example", updated["disabledPlugins"])
async def test_click_toggles_plugin_like_space(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_two_plugin_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
table = app.query_one(DataTable)
self.assertEqual(table.row_count, 2)
self.assertEqual(app.plugin_rows[0]["status"], "enabled")
# Header is y=0; first data row is y=1. Click the second plugin row.
await pilot.click(DataTable, offset=(2, 2))
await pilot.pause()
self.assertEqual(table.cursor_row, 1)
self.assertEqual(app.plugin_rows[1]["id"], "beta")
self.assertEqual(app.plugin_rows[1]["status"], "disabled")
self.assertFalse((instance / "Plugins" / "Beta.dll").exists())
self.assertTrue((instance / "Plugins" / "Alpha.dll").exists())
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertNotIn("beta", updated["plugins"])
self.assertIn("beta", updated["disabledPlugins"])
self.assertIn("alpha", updated["plugins"])
async def test_click_toggles_already_selected_plugin(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
self.assertEqual(app.plugin_rows[0]["status"], "enabled")
await pilot.click(DataTable, offset=(2, 1))
await pilot.pause()
self.assertEqual(app.plugin_rows[0]["status"], "disabled")
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
self.assertNotIn("example", updated["plugins"])
self.assertIn("example", updated["disabledPlugins"])
async def test_space_toggle_keeps_cursor_on_selected_plugin(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, _state = _make_two_plugin_tui_fixture(Path(tmp))
@@ -2770,6 +2809,50 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("example", updated.get("disabledPlugins", {}))
self.assertIn("hash mismatch", app.status_message)
async def test_slash_filters_plugin_list(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, _instance, _state = _make_two_plugin_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
table = app.query_one(DataTable)
self.assertEqual(table.row_count, 2)
await pilot.press("/")
await pilot.pause()
filter_input = app.query_one("#filter", Input)
self.assertFalse(filter_input.has_class("-hidden"))
self.assertTrue(filter_input.has_focus)
await pilot.press("b", "e", "t")
await pilot.pause()
self.assertEqual(app.filter_query, "bet")
self.assertEqual(table.row_count, 1)
self.assertEqual(app._filtered_plugins()[0]["id"], "beta")
await pilot.press("enter")
await pilot.pause()
self.assertFalse(filter_input.has_focus)
self.assertEqual(table.row_count, 1)
await pilot.press("space")
await pilot.pause()
self.assertEqual(app.plugin_rows[1]["id"], "beta")
self.assertEqual(app.plugin_rows[1]["status"], "disabled")
async def test_escape_clears_plugin_filter(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, _instance, _state = _make_two_plugin_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
table = app.query_one(DataTable)
await pilot.press("/")
await pilot.press("a", "l")
await pilot.pause()
self.assertEqual(table.row_count, 1)
self.assertEqual(app._filtered_plugins()[0]["id"], "alpha")
await pilot.press("escape")
await pilot.pause()
self.assertEqual(app.filter_query, "")
self.assertEqual(table.row_count, 2)
self.assertTrue(app.query_one("#filter", Input).has_class("-hidden"))
self.assertIn("Filter cleared", app.status_message)
if __name__ == "__main__":
unittest.main()