Compare commits

...

15 Commits

Author SHA1 Message Date
pleb ed71a6d081 Minor doc changes 2026-07-23 17:00:25 -07:00
pleb 4dea85b869 Redact instructions for making PRs, it was a dumb idea 2026-07-23 16:58:25 -07:00
pleb 544e4cbd29 Update accsaber 2026-07-23 16:36:47 -07:00
pleb e48c677cc0 Update setlist, beatsabervoting, and patch squattobegin 2026-07-23 16:01:09 -07:00
pleb 1d9698fcbc Add wipbot local build 2026-07-16 17:00:07 -07:00
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
17 changed files with 491 additions and 75 deletions
-15
View File
@@ -43,21 +43,6 @@ 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.
+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:
+1 -1
View File
@@ -106,7 +106,7 @@ grep Setlist Logs/_latest.log
```
Expected Setlist signals include `platformUserId=...`, playlist lines with
`hasSyncUrl=...`, and `beatLeaderOwnerConfirmed=...`. When testing real playlist
`hasSyncUrl=...`, and `ownerMatchesPlatform=...`. When testing real playlist
sync, adding a map to an owned BeatLeader-synced playlist should log an HTTP
success line.
@@ -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,12 +226,12 @@ 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. |
| 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. |
| Setlist | | <span style="color:#3fb950; font-weight:600">verified</span> | Local build from `/home/pleb/ops/beatsaber/setlist` commit `14d21ad` with working tree modifications; asset `Setlist.dll`, SHA-256 `9806d878951958960e82582d219af360e5d67ec2806867900e1374659bfdec2e`; version 0.1.1, manifest `gameVersion` 1.44.1 | Replaces the earlier skipped local builds (`01ecba3c...` and `57f07f5d...`). Fixes the startup scan by waiting briefly for BeatSaberPlaylistsLib playlists instead of returning after an empty early read; dependency gate remains widened to `BeatLeader >=0.9.0`. Smoke on 2026-07-19 loaded Setlist 0.1.1, resolved `platformUserId=76561199407393962`, logged four owned BeatLeader playlists with `ownerMatchesPlatform=true`, and reached `MainSystemInit`. Real add-to-playlist POST sync was not exercised. |
### Batch 7: Cosmetic, Camera, and Lighting
@@ -255,6 +245,7 @@ Purpose: add visual and stream-facing mods after functional mods are stable.
| 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. |
| Cinema | [github](https://github.com/MisterKelley/BeatSaberCinema/tree/bs-1.42.3) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Local build from `MisterKelley/BeatSaberCinema` branch `bs-1.42.3` commit `010ce6e01728af1e21774ccbb8d14405773f5b02`, asset `BeatSaberCinema-1.7.13-bs1.42.3-010ce6e.zip`, SHA-256 `cd128597b0b0aab2b152eda16849677dd0b52b6f0aebfdaae3ce0c07809d83d7`; source checkout has a local Linux build fix for `BGLib.AppFlow.dll` hint-path casing. | Helper installed `Plugins/BeatSaberCinema.dll` SHA-256 `1dfa5d885ddd1713f8161a266062b413743198961adae87ead45c8ace066ef61` and enabled locked BetterSongList 0.4.3. IPA loaded Cinema 1.7.13 and BetterSongList 0.4.3; Cinema logged hardware info, Steam initialized, and startup reached `MainSystemInit` plus menu installers. Warnings: manifest targets Beat Saber 1.42.3; Cinema failed to register its BetterSongList filter (`Object of type 'BeatSaberCinema.HasVideoFilter' cannot be converted to type 'T'`); Cinema reported missing `yt-dlp`/`ffmpeg` libraries under `Libs`, so video downloading will not work until those are added. |
| wipbot | [github](https://github.com/Danielduel/wipbot) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Local build from `C:\Users\pleb\src\Danielduel\wipbot` branch `bs-1.44.1-compat` commit `c717a84`, asset `wipbot.dll`, SHA-256 `c895d45b8e842f1d408b9f53864fc4fc1c2bb6dda4b992d0bb3a6fdc12c0e0a8`; replaces upstream GitHub `1.21.0` (labeled for 1.40.8). Changes: manifest `gameVersion` `1.44.0` / version `1.22.0`; Harmony `PatchAll` scoped to the patch type; CatCore compile-optional so ChatPlex-only installs avoid `ReflectionTypeLoadException`. | Helper installed `Plugins/wipbot.dll` into Windows BSManager `1.44.1` on 2026-07-16. IPA loaded wipbot 1.22.0 and logged `Using ChatPlexSDK for chat`; the prior CatCore Harmony `ReflectionTypeLoadException` did not recur. Warning: manifest targets Beat Saber 1.44.0. Smoke did not reach the main menu because Steam was not running (`SteamAPI` / platform restart). Twitch `!wip` download/queue UI not exercised. |
### Batch 8: Paid or Closed-Source Plugins
@@ -267,7 +258,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 +279,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). |
@@ -340,4 +331,5 @@ Record plugins skipped for this 1.44.1 pass. This is not a fix list for today.
| 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. |
| 7b | 2026-07-16 | <span style="color:#d29922; font-weight:600">verified with warning</span> | Local-built wipbot 1.22.0 installed into Windows BSManager `1.44.1`; IPA loaded wipbot and logged `Using ChatPlexSDK for chat` with no CatCore Harmony `ReflectionTypeLoadException`. | Warning: manifest targets 1.44.0. Steam was not running, so the smoke did not reach `MainSystemInit`; Twitch `!wip` / WIP button not exercised. |
| 8 | 2026-06-29 | <span style="color:#d29922; font-weight:600">verified with warning</span> | FPFC smoketest `Logs/2026.06.29.15.32.05.log`: BeatSaberPlus 6.4.5 bundle and ChatPlexSDK_BS loaded and initialized all modules; ten Patreon Naluluna packages loaded (`NalulunaUtils`, `NalulunaMenu`, `NalulunaAvatars`, `NalulunaSkybox`, `NalulunaShaders`, `NalulunaCounters`, `NalulunaEnergy`, `NalulunaLevelDetail`, `NalulunaSongPreview`, `NalulunaRewinder`); NalulunaUtils registered menu buttons and `OnMenuSceneActive: Succeeded`; NalulunaMenu played default menu music; the game reached `MainSystemInit`. | Warnings: BeatSaberPlus/ChatPlex and several Naluluna manifests target older Beat Saber versions; NalulunaUtils logged missing satellite resources and optional libs; NalulunaAvatars referenced a missing configured VRM and hit one menu `NullReferenceException`; pre-existing LeaderboardCore Harmony patch error still appears; slice/miss/fps/ppcoin Naluluna mods remain uninstalled; in-song Naluluna features and VR avatar load were not exercised. |
+12
View File
@@ -0,0 +1,12 @@
```powershell
cd C:\Program Files (x86)\Steam\steamapps\common\Beat Saber\logs>
Get-Content .\_latest.log -Wait -Tail 50
```
`-Tail 50` shows the last 50 lines; `-Wait` keeps following new output (like `tail -f`).
Shorter alias:
```powershell
gc .\_latest.log -Wait -Tail 50
```
+71 -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.20"
asset = "1.42.0.zip"
download_url = "https://github.com/not-dexter/accsaber-reloaded-plugin/releases/download/v1.20/1.42.0.zip"
sha256 = "0fccbc51001ef16ef1fe1ca89db6729670e831e05c709542c27e3ff756c520d8"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub releases URL https://github.com/not-dexter/accsaber-reloaded-plugin/releases. Latest release v1.20 (v1.2.0) has no 1.44.x asset; use highest available game-version asset 1.42.0.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,24 @@ 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 = "beatsavervoting"
repo = "Top-Cat/BeatSaverVoting"
tag = "localbuild-bs-1.44.1-2.4.8"
asset = "BeatSaverVoting.dll"
sha256 = "2e35638d29676e01a21b854209a74eaa12a347faab3c8b789442d05554658e98"
install_strategy = "dll-to-plugins"
reason = "Local build from ~/src/Top-Cat/BeatSaverVoting branch bs-1.44.1 (based on upstream 5616be4 / 2.4.7 IPlatform migration). Bumps to 2.4.8 for Beat Saber 1.44.1, retargets the LevelListTableCell.SetDataFromLevelAsync Harmony patch to the 1.44 signature, and drops the unused PlatformUserModel reference. Replaces failed BeatMods 2.4.6 trial that TypeLoadException'd on IPlatformUserModel."
[[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 +319,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 +501,36 @@ 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."
[[plugins]]
id = "wipbot"
repo = "Danielduel/wipbot"
tag = "localbuild-c717a84-1.44"
asset = "wipbot.dll"
sha256 = "c895d45b8e842f1d408b9f53864fc4fc1c2bb6dda4b992d0bb3a6fdc12c0e0a8"
install_strategy = "dll-to-plugins"
reason = "Local build from C:\\Users\\pleb\\src\\Danielduel\\wipbot commit c717a84 on branch bs-1.44.1-compat. Builds cleanly against Beat Saber 1.44.1; bumps manifest to gameVersion 1.44.0 / version 1.22.0; Harmony PatchAll scoped to patch type and CatCore compile-optional so ChatPlex-only installs avoid ReflectionTypeLoadException."
[[plugins]]
id = "setlist"
repo = "https://git.plebsaber.stream/pleb/setlist"
tag = "v0.1.2"
asset = "Setlist-0.1.2-bs1.44.1-e44e5e8.zip"
download_url = "https://git.plebsaber.stream/pleb/setlist/releases/download/v0.1.2/Setlist-0.1.2-bs1.44.1-e44e5e8.zip"
sha256 = "242677be9e5673100f7b737349698c8ffdffd95d5f23fdbc816ffd625f678e74"
install_strategy = "bsipa-zip"
reason = "Gitea release https://git.plebsaber.stream/pleb/setlist/releases/tag/v0.1.2 for Beat Saber 1.44.1 (commit e44e5e8). Replaces the earlier local 0.1.0 trial build that was removed after an incomplete playlist-load smoke."
+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" },
]
+2 -2
View File
@@ -1,8 +1,8 @@
id = "beatsavervoting"
name = "BeatSaverVoting"
repo = "Top-Cat/BeatSaverVoting"
asset_patterns = ["BeatSaverVoting-*.zip"]
install_strategy = "bsipa-zip"
asset_patterns = ["BeatSaverVoting.dll", "BeatSaverVoting-*.zip"]
install_strategy = "dll-to-plugins"
category = "downloader"
dependencies = [
{ id = "bs-utils", constraint = ">=1.14.3" },
+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" },
]
+3 -2
View File
@@ -1,7 +1,8 @@
id = "setlist"
name = "Setlist"
asset_patterns = ["Setlist.dll"]
install_strategy = "dll-to-plugins"
repo = "https://git.plebsaber.stream/pleb/setlist"
asset_patterns = ["Setlist-*.zip", "Setlist.dll"]
install_strategy = "bsipa-zip"
category = "ui"
dependencies = [
{ id = "beatleader", constraint = ">=0.9.0" },
+11
View File
@@ -0,0 +1,11 @@
id = "wipbot"
name = "wipbot"
repo = "Danielduel/wipbot"
asset_patterns = ["wipbot.dll"]
install_strategy = "dll-to-plugins"
category = "streaming"
dependencies = [
{ id = "beatsabermarkuplanguage", constraint = ">=1.8.0" },
{ id = "songcore", constraint = ">=3.0.0" },
{ id = "bsipa", constraint = ">=4.0.5" },
]
@@ -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),
+213 -17
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:
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()