Compare commits
7 Commits
caaa4a6558
...
17bd736e59
| Author | SHA1 | Date | |
|---|---|---|---|
| 17bd736e59 | |||
| f085bbb802 | |||
| 8ad2a3dd35 | |||
| 158bc23298 | |||
| 7639fb7270 | |||
| 931c1d4f73 | |||
| 5a9e873de4 |
@@ -1,15 +1,19 @@
|
||||
---
|
||||
name: install-beatsaber-plugin
|
||||
description: Install or update a Beat Saber plugin in the plugin-helper repo by using the local helper workflow. Use when the user asks to add, install, update, bump, lock, or manage a Beat Saber plugin release for a BSManager instance. The user must provide an explicit GitHub release URL in the prompt; if no release URL is present, ask for one and do not infer, search for, or substitute a different repository.
|
||||
description: Install or update a Beat Saber plugin in the plugin-helper repo by using the local helper workflow. Use when the user asks to add, install, update, bump, lock, bootstrap BSIPA, or manage a Beat Saber plugin release for a BSManager instance. Prefer upstream GitHub release artifacts for normal plugins; use BeatMods primarily as compatibility/dependency metadata, with CDN artifacts only for inaccessible upstream assets, BeatMods-only packages, or framework/library dependencies.
|
||||
---
|
||||
|
||||
# Install Beat Saber Plugin
|
||||
|
||||
Use the repository's own `plugin-helper` commands to manage plugins for BSManager instances. Do not manually copy release files into the game instance except to undo your own mistaken install before rerunning the helper.
|
||||
Use the repository's own `plugin-helper` commands to manage plugins for BSManager instances whenever the helper supports the operation. Do not manually copy release files into the game instance except:
|
||||
|
||||
- to bootstrap BSIPA/core packages before the helper has a first-class bootstrap command
|
||||
- to undo your own mistaken install before rerunning the helper
|
||||
|
||||
## Hard Guardrail
|
||||
|
||||
Require an explicit GitHub release URL from the user's prompt before selecting any release or repository.
|
||||
For ordinary GitHub-hosted plugins, require an explicit GitHub release URL from
|
||||
the user's prompt before selecting any release or repository.
|
||||
|
||||
- If the prompt does not contain a GitHub release URL, stop and ask the user for it.
|
||||
- Do not search the web to discover a repository or "correct" release URL.
|
||||
@@ -17,6 +21,17 @@ Require an explicit GitHub release URL from the user's prompt before selecting a
|
||||
- If the provided URL is a general releases page, use that repo's release API and choose the latest non-draft, non-prerelease release unless the user asks for a specific tag/version.
|
||||
- If the provided URL is a tag URL, use that exact tag.
|
||||
|
||||
Exception: if the user explicitly asks to bootstrap a Beat Saber version or
|
||||
install verified mods without providing GitHub URLs, use BeatMods metadata to
|
||||
identify compatible versions and dependency closure. Still prefer upstream
|
||||
GitHub release artifacts when BeatMods exposes a `gitUrl` and a matching
|
||||
release/asset can be found. Use BeatMods CDN artifacts only when the upstream
|
||||
artifact is inaccessible, no matching upstream release asset exists, the package
|
||||
is effectively BeatMods-only, or the package is a framework/library dependency
|
||||
such as .NET assemblies. Record the artifact source plus BeatMods `modVersion`,
|
||||
version id, `zipHash`, dependencies, and supported game version in the repo
|
||||
notes/lock data.
|
||||
|
||||
Accepted URL shapes include:
|
||||
|
||||
```text
|
||||
@@ -44,6 +59,7 @@ https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
|
||||
sed -n '1,220p' src/plugin_helper/models.py
|
||||
sed -n '1,220p' registry/plugins.toml
|
||||
sed -n '1,220p' locks/<instance>.lock.toml
|
||||
sed -n '1,220p' docs/SMOKETEST.md
|
||||
```
|
||||
|
||||
3. Determine the instance.
|
||||
@@ -54,21 +70,42 @@ https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
|
||||
PYTHONPATH=src python -m plugin_helper instances
|
||||
```
|
||||
|
||||
4. Snapshot first when requested.
|
||||
4. Resolve the release source.
|
||||
|
||||
If the user asks for a one-time or pre-helper snapshot, archive the instance's `Plugins/` directory before any install:
|
||||
For BeatMods bootstrap or verified packages, query BeatMods with a browser-like user agent:
|
||||
|
||||
```bash
|
||||
mkdir -p "$HOME/archive/beatsaber"
|
||||
tar -C "<instance-root>/<instance>" -czf "$HOME/archive/beatsaber/<instance>-Plugins-pre-helper-<timestamp>.tar.gz" Plugins
|
||||
sha256sum "$HOME/archive/beatsaber/<instance>-Plugins-pre-helper-<timestamp>.tar.gz"
|
||||
python - <<'PY'
|
||||
import json, urllib.request
|
||||
game_version = "<instance>"
|
||||
url = f"https://beatmods.com/api/mods?status=verified&gameVersion={game_version}&gameName=BeatSaber&platform=steampc"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 plugin-helper"})
|
||||
with urllib.request.urlopen(req, timeout=20) as response:
|
||||
data = json.load(response)
|
||||
mods = data["mods"] if isinstance(data, dict) and "mods" in data else data
|
||||
print(json.dumps(mods, indent=2)[:20000])
|
||||
PY
|
||||
```
|
||||
|
||||
Report the archive path and hash.
|
||||
BeatMods dependency entries are mod-version ids. Resolve the selected mod's
|
||||
dependency closure before downloading. For each resolved package, prefer its
|
||||
upstream `gitUrl` release artifacts when a matching release asset exists.
|
||||
Fall back to BeatMods CDN only for inaccessible/missing upstream assets,
|
||||
BeatMods-only packages, or framework/library dependencies. CDN URLs are:
|
||||
|
||||
5. Resolve the release from the user-provided URL only.
|
||||
```text
|
||||
https://beatmods.com/cdn/mod/<zipHash>.zip
|
||||
```
|
||||
|
||||
For GitHub URLs, derive `<owner>/<repo>` and optional `<tag>` from the URL. Query the GitHub API directly for metadata:
|
||||
For BSIPA bootstrap, expect the archive to contain root-relative `IPA/` and
|
||||
`IPA.exe` files whether sourced from GitHub or BeatMods. Extract it into the
|
||||
instance root and run `IPA.exe -n` under the same Proton environment used by
|
||||
the smoketest. This creates/copies the root `winhttp.dll` and root `Libs/`
|
||||
substrate that IPA needs.
|
||||
|
||||
For GitHub URLs, resolve the release from the user-provided URL only.
|
||||
|
||||
Derive `<owner>/<repo>` and optional `<tag>` from the URL. Query the GitHub API directly for metadata:
|
||||
|
||||
```bash
|
||||
curl -sS https://api.github.com/repos/<owner>/<repo>/releases
|
||||
@@ -77,31 +114,31 @@ https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
|
||||
|
||||
Pick the asset that matches the Beat Saber instance/version. Prefer an exact versioned asset such as `1.40.8.zip` over broad or source archives. If multiple plausible assets remain, ask the user.
|
||||
|
||||
6. Inspect the asset before selecting an install strategy.
|
||||
5. Inspect the asset before selecting an install strategy.
|
||||
|
||||
Download to the helper state directory:
|
||||
|
||||
```bash
|
||||
mkdir -p .state/instances/<instance>/downloads
|
||||
curl -L --fail -o .state/instances/<instance>/downloads/<asset-name> "<browser_download_url>"
|
||||
sha256sum .state/instances/<instance>/downloads/<asset-name>
|
||||
mkdir -p .state/instances/<instance>/downloads/<plugin-id>
|
||||
curl -L --fail -o .state/instances/<instance>/downloads/<plugin-id>/<asset-name> "<browser_download_url>"
|
||||
sha256sum .state/instances/<instance>/downloads/<plugin-id>/<asset-name>
|
||||
```
|
||||
|
||||
Match the checksum against GitHub's `digest` when available. Inspect zip contents:
|
||||
|
||||
```bash
|
||||
unzip -l .state/instances/<instance>/downloads/<asset-name>
|
||||
unzip -l .state/instances/<instance>/downloads/<plugin-id>/<asset-name>
|
||||
```
|
||||
|
||||
Strategy guide:
|
||||
|
||||
- `dll-to-plugins`: asset is a single `.dll` that belongs in `Plugins/`.
|
||||
- `bsipa-zip`: zip top-level paths are only `IPA/`, `Libs/`, or `Plugins/`.
|
||||
- `root-zip`: zip contains valid game-root paths outside the BSIPA top-level set.
|
||||
- `root-zip`: zip contains valid game-root paths outside the BSIPA top-level set. Use this for BSIPA/bootstrap archives because `IPA.exe`, `IPA.runtimeconfig*.json`, and root `winhttp.dll` are game-root files.
|
||||
- `zip-to-pending`: only when the release is intended for `IPA/Pending/`.
|
||||
- `manual`: do not use for installable releases.
|
||||
|
||||
7. Update the registry and lockfile.
|
||||
6. Update the registry and lockfile.
|
||||
|
||||
Add or update exactly one `[[plugins]]` entry in `registry/plugins.toml` with:
|
||||
|
||||
@@ -128,7 +165,7 @@ https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
|
||||
|
||||
Preserve unrelated registry and lockfile content. Do not invent dependency versions unless they are explicitly stated by the provided release notes or existing local metadata.
|
||||
|
||||
8. Use the helper to validate, plan, and apply.
|
||||
7. Use the helper to validate, plan, and apply.
|
||||
|
||||
Always pass `--state-dir .state` so the helper uses the repo-local downloaded asset:
|
||||
|
||||
@@ -140,7 +177,7 @@ https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
|
||||
|
||||
Before applying, read or summarize the generated plan enough to confirm it changes only the intended plugin files.
|
||||
|
||||
9. Verify the result.
|
||||
8. Verify the result.
|
||||
|
||||
Confirm the installed file hashes match the plan or archive members:
|
||||
|
||||
@@ -152,12 +189,33 @@ https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
|
||||
|
||||
Use `PYTHONPATH=src`; plain `python -m unittest` may fail in this source-layout repo.
|
||||
|
||||
10. Final response.
|
||||
For live Beat Saber validation, follow `docs/SMOKETEST.md`. Do not rely on
|
||||
`timeout` to kill the full game process tree. Prefer the documented
|
||||
foreground Proton launch with a background watchdog that sleeps for the smoke
|
||||
window, then terminates Beat Saber by process name. Confirm `Logs/_latest.log`
|
||||
has the expected IPA/plugin lines. If the game remains open after the
|
||||
watchdog cleanup, say so and ask the user to close it manually rather than
|
||||
leaving the turn with Beat Saber running.
|
||||
|
||||
For BSIPA/SongCore bootstrap, expected successful log lines include:
|
||||
|
||||
```text
|
||||
Game version <version>
|
||||
Loading plugins from Plugins and found <n>
|
||||
Beat Saber IPA (BSIPA): <version>
|
||||
SongCore (SongCore): <version>
|
||||
```
|
||||
|
||||
Warnings about older mod target game-version metadata can be acceptable when
|
||||
BeatMods verified that exact package for the target Beat Saber version, but
|
||||
record them in the tracker or roadmap. Also record when a BeatMods CDN
|
||||
artifact was used so it can be migrated to upstream GitHub later if possible.
|
||||
|
||||
9. Final response.
|
||||
|
||||
Include:
|
||||
|
||||
- release URL/tag/asset used
|
||||
- snapshot path and hash, if created
|
||||
- files changed in the repo and helper state
|
||||
- live instance files changed
|
||||
- backup path created by the helper
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for coding agents working in this repo.
|
||||
|
||||
## Project Shape
|
||||
|
||||
- This repo manages Beat Saber plugins for BSManager instances.
|
||||
- Default instance roots are:
|
||||
- `/home/pleb/Windows/Users/pleb/BSManager/BSInstances`
|
||||
- `/home/pleb/.local/share/BSManager/BSInstances`
|
||||
- A local BSManager source checkout may be available at
|
||||
`/home/pleb/src/Zagrios/bs-manager`. Use it as a read-only reference when
|
||||
investigating launch behavior, inherited Steam arguments, instance layout, or
|
||||
Proton environment details unless the user explicitly asks for BSManager code
|
||||
changes.
|
||||
- Prefer repo-local state with `--state-dir .state` for planned installs unless
|
||||
the task explicitly targets the user's live default state.
|
||||
|
||||
## Workflow Rules
|
||||
|
||||
- Run commands from the repo root with `PYTHONPATH=src`.
|
||||
- For human-style inspection, prefer the menu with repo-local state:
|
||||
`PYTHONPATH=src python -m plugin_helper --state-dir .state menu`.
|
||||
- When targeting the local Linux BSManager install rather than the Windows
|
||||
mirror, pass `--instances-root /home/pleb/.local/share/BSManager/BSInstances`
|
||||
or choose that path explicitly in the menu.
|
||||
- Use the helper commands instead of manually copying plugin files into an
|
||||
instance.
|
||||
- Treat BSIPA as a bootstrap phase:
|
||||
- `bootstrap` installs the locked BSIPA archive and records generated files.
|
||||
- ordinary plugin plans should depend on healthy bootstrap state.
|
||||
- Be careful with duplicate instance names across Windows and local roots. Use
|
||||
the menu or pass `--instances-root` explicitly when targeting one install.
|
||||
|
||||
## Validation
|
||||
|
||||
- Run `PYTHONPATH=src python -m unittest discover -s tests` after code changes.
|
||||
- Run `PYTHONPATH=src python -m compileall -q src tests` for syntax/import
|
||||
checks.
|
||||
- For live game validation, follow `docs/SMOKETEST.md` and tear down Beat Saber
|
||||
processes afterward.
|
||||
|
||||
## Launch Notes
|
||||
|
||||
- BSManager may inherit Beat Saber launch arguments configured in Steam.
|
||||
- Do not assume a black screen is a plugin failure until checking
|
||||
`Logs/_latest.log`, Unity `Player.log`, and the live process command line.
|
||||
- Duplicate launch args such as `--no-yeet fpfc --no-yeet fpfc` can trigger a
|
||||
fatal command-line parse error after BSIPA/plugin loading succeeds.
|
||||
@@ -1,6 +1,6 @@
|
||||
# plugin-helper
|
||||
|
||||
`plugin-helper` is an early Python CLI for managing Beat Saber plugins in a mounted Windows BSManager install.
|
||||
`plugin-helper` is an early Python CLI for managing Beat Saber plugins in BSManager installs.
|
||||
|
||||
The first implementation focuses on safe local workflows:
|
||||
|
||||
@@ -8,30 +8,101 @@ The first implementation focuses on safe local workflows:
|
||||
- scan existing `Plugins/` and `Libs/` files
|
||||
- read checked-in registry and per-version lockfiles
|
||||
- generate a machine-readable install plan from local release assets
|
||||
- apply exactly that plan with backups and install state
|
||||
- apply exactly that plan and record install state
|
||||
- uninstall only files recorded in install state
|
||||
- back up `UserData` separately
|
||||
|
||||
Default BSManager instance root:
|
||||
Default BSManager instance roots:
|
||||
|
||||
```text
|
||||
/home/pleb/Windows/Users/pleb/BSManager/BSInstances
|
||||
/home/pleb/.local/share/BSManager/BSInstances
|
||||
```
|
||||
|
||||
Override with `--instances-root` or `PLUGIN_HELPER_INSTANCES_ROOT`.
|
||||
Override with `--instances-root` or `PLUGIN_HELPER_INSTANCES_ROOT`. To search
|
||||
multiple explicit roots, separate them with `:`.
|
||||
|
||||
## Quick Start
|
||||
## Commands
|
||||
|
||||
For normal use, run the menu from the repo root. Use repo-local state so the
|
||||
menu sees the same plans, downloads, and install records used by the helper
|
||||
workflow:
|
||||
|
||||
```sh
|
||||
python -m plugin_helper instances
|
||||
python -m plugin_helper scan --instance 1.40.8
|
||||
python -m plugin_helper plan --instance 1.40.8 --state-dir .state
|
||||
PYTHONPATH=src python -m plugin_helper --state-dir .state menu
|
||||
```
|
||||
|
||||
The individual subcommands are mostly for automation and debugging. If you use
|
||||
them, pass `--state-dir .state` unless you intentionally want the default live
|
||||
state outside this repo.
|
||||
|
||||
Install assets are currently expected to already exist locally, usually under:
|
||||
|
||||
```text
|
||||
.state/instances/<instance>/downloads/
|
||||
.state/instances/<instance>/downloads/<plugin-id>/
|
||||
```
|
||||
|
||||
Future milestones will add GitHub release discovery and download.
|
||||
## Beat Saber Data Backups
|
||||
|
||||
`backup-userdata` copies the mounted Windows `UserData` folder and Beat Saber
|
||||
Windows app data into the adjacent `../backups` repo. With the Windows mount at
|
||||
`~/Windows`, the helper infers Beat Saber's Windows app data as:
|
||||
|
||||
```text
|
||||
/home/pleb/Windows/Users/pleb/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber
|
||||
```
|
||||
|
||||
Example manual backup after mounting Windows:
|
||||
|
||||
```sh
|
||||
PYTHONPATH=src python -m plugin_helper \
|
||||
--instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances \
|
||||
backup-userdata \
|
||||
--instance 1.44.1
|
||||
```
|
||||
|
||||
By default the backup repo receives plain copied files under
|
||||
`../backups/beat-saber/UserData` and `../backups/beat-saber/AppData`, plus
|
||||
`../backups/beat-saber/backup-descriptor.json` describing the source paths from
|
||||
the latest backup run. Use
|
||||
`--appdata-path <path>` if the Windows profile path ever differs, `--no-appdata`
|
||||
for a `UserData`-only sync, or `--backup-root <path>` to choose a different
|
||||
destination.
|
||||
|
||||
The backup intentionally omits bulky/generated data:
|
||||
|
||||
- `UserData/BeatLeader/Replays`
|
||||
- `UserData/BeatLeader/ReplayerCache`
|
||||
- `UserData/BeatLeader/LeaderboardsCache`
|
||||
- `UserData/BeatLeader/ReplayHeadersCache`
|
||||
- `UserData/ScoreSaber/Replays`
|
||||
- `UserData/BeatSaberPlus/Cache`
|
||||
- `UserData/BeatSaverNotifier.json`
|
||||
- `UserData/Accsaber/PlayerScoreCache.json`
|
||||
- `UserData/NalulunaAvatars/cache`
|
||||
- `UserData/SongDetailsCache.proto`
|
||||
- `AppData/com.unity.addressables`
|
||||
- `*.log`
|
||||
|
||||
Other large folders seen in the 1.44.1 `UserData` tree are
|
||||
`Custom Campaigns`, `SongCore`, `AssetBundleLoadingTools`, `NalulunaMenu`, and
|
||||
`NalulunaSkybox`. Those are not skipped by default because they can contain
|
||||
custom content or non-obvious user choices rather than pure cache data.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- BSManager can inherit launch arguments configured in Steam for Beat Saber.
|
||||
Check both places before debugging black screens or startup hangs. Duplicating
|
||||
arguments such as `--no-yeet fpfc` can make the game fail command-line
|
||||
parsing after BSIPA and plugins have already loaded.
|
||||
- BSIPA is managed as a first-class bootstrap phase. The `bootstrap` command
|
||||
applies the locked `bsipa` root archive, runs `IPA.exe -n` through Proton, and
|
||||
records every bootstrap-relevant file under root `IPA.exe*`, `winhttp.dll`,
|
||||
`Libs/`, and `IPA/`, including backups created during patching.
|
||||
- If an instance lockfile includes `bsipa`, ordinary plugin plans require a
|
||||
recorded bootstrap state plus a `Logs/_latest.log` that shows BSIPA startup.
|
||||
Use `bootstrap-check` before planning a batch when you want a quick gate.
|
||||
- Use [`docs/SMOKETEST.md`](docs/SMOKETEST.md) after installing or removing a
|
||||
plugin batch. It documents the short Proton/BSManager launch loop, IPA log
|
||||
checks, and teardown commands.
|
||||
- The 1.44.1 migration tracker lives in
|
||||
[`docs/notes/install-and-verify-plugins-1.44.1.md`](docs/notes/install-and-verify-plugins-1.44.1.md).
|
||||
|
||||
@@ -76,6 +76,7 @@ Runtime state should not need to live inside the repository. By default, keep mu
|
||||
installed.json
|
||||
plans/
|
||||
downloads/
|
||||
<plugin-id>/
|
||||
backups/
|
||||
```
|
||||
|
||||
@@ -85,6 +86,17 @@ For early development, a `--state-dir` option is useful so plans and manifests c
|
||||
|
||||
The registry describes plugin sources and install behavior. It should be human-editable because many Beat Saber plugins have small packaging differences.
|
||||
|
||||
Artifact source preference:
|
||||
|
||||
1. Prefer upstream GitHub release artifacts for normal plugins.
|
||||
2. Use BeatMods as compatibility, dependency, and verification metadata even
|
||||
when the artifact comes from GitHub.
|
||||
3. Use BeatMods CDN artifacts only when the upstream artifact is inaccessible,
|
||||
the package is effectively BeatMods-only, or the package is a framework or
|
||||
library dependency that does not have a normal plugin release source.
|
||||
4. Record both the artifact source and any BeatMods `modVersion`/version-id/
|
||||
`zipHash` metadata used to justify compatibility.
|
||||
|
||||
Example:
|
||||
|
||||
```toml
|
||||
|
||||
+23
-6
@@ -8,6 +8,10 @@ The initial tool should stay conservative:
|
||||
|
||||
- Python owns instance discovery, dry-run plans, activation, install state, uninstall, and `UserData` backups.
|
||||
- Release assets are selected through registry and lockfile data.
|
||||
- Prefer upstream GitHub release artifacts for normal plugins. Use BeatMods as
|
||||
compatibility/dependency metadata, and as an artifact source only for
|
||||
inaccessible upstream artifacts, BeatMods-only packages, or framework/library
|
||||
dependencies.
|
||||
- Mutating operations apply an explicit plan and record exact file hashes.
|
||||
- Nix packages `plugin-helper`, but does not directly manage the mutable Beat Saber tree.
|
||||
|
||||
@@ -64,12 +68,25 @@ All modes should still produce the same kind of explicit plan before applying.
|
||||
## Proposed Milestones
|
||||
|
||||
1. Keep the Python safety harness stable: scan, plan, apply, uninstall, and backups.
|
||||
2. Model one real plugin end to end with the current TOML lockfile and local asset planning.
|
||||
3. Add a Nix function that fetches and unpacks one locked plugin asset into a normalized tree.
|
||||
4. Generate a full plugin-set derivation for one Beat Saber version.
|
||||
5. Teach `plugin-helper plan` to compare a Nix output tree against an instance.
|
||||
6. Add `--activation-mode copy|symlink|materialize`.
|
||||
7. Move compatibility and dependency metadata toward shared data that both Python and Nix can consume.
|
||||
2. Model BSIPA bootstrap as a first-class install phase, preferring upstream GitHub release artifacts while preserving BeatMods `zipHash`/version metadata when used for verification or fallback.
|
||||
3. Resolve BeatMods dependency closures by mod-version id for verified mods before ordinary batch planning, but keep artifact sourcing GitHub-preferred.
|
||||
4. Model one real plugin end to end with the current TOML lockfile and local asset planning.
|
||||
5. Add a Nix function that fetches and unpacks one locked plugin asset into a normalized tree.
|
||||
6. Generate a full plugin-set derivation for one Beat Saber version.
|
||||
7. Teach `plugin-helper plan` to compare a Nix output tree against an instance.
|
||||
8. Add `--activation-mode copy|symlink|materialize`.
|
||||
9. Move compatibility and dependency metadata toward shared data that both Python and Nix can consume.
|
||||
|
||||
## Warning Follow-Ups From 1.44.1 Bootstrap
|
||||
|
||||
The first 1.44.1 BSIPA/SongCore smoketest worked, but it produced warnings worth tracking separately from install success:
|
||||
|
||||
- BSML, SiraUtil, and SongCore have older target game-version metadata even though BeatMods verifies the selected releases for 1.44.1. Decide whether plugin-helper should treat BeatMods verification as a compatibility override.
|
||||
- The first bootstrap used BeatMods CDN artifacts for speed. BSIPA, BSML, and SiraUtil have now been matched to byte-identical upstream GitHub release assets. SongCore remains a BeatMods CDN fallback because the BeatMods preferred repo `Kylemc1413/SongCore` currently exposes no matching 3.16.0 GitHub release asset.
|
||||
- BSML reports missing Windows fonts under Proton. This is likely cosmetic, but may affect Unicode text rendering in mod UI.
|
||||
- SongCore warns that `Beat Saber_Data/CustomWIPLevels/Cache` has no `Info.dat`. Either create the expected cache directory shape or classify this warning as harmless.
|
||||
- SongCore could not read the audio rate for the built-in `Magic.wav` custom level and approximated duration from map length. Check whether this is a bundled-song oddity or a broader audio metadata issue.
|
||||
- The smoketest launcher can leave Beat Saber running after timeout. Prefer explicit teardown and consider a helper command that starts, watches logs, and kills the process tree deterministically.
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# Beat Saber Smoketest Workflow
|
||||
|
||||
Use this workflow after installing or removing a plugin batch. It is adapted
|
||||
from the Setlist repo's working Proton/BSManager smoketest notes.
|
||||
|
||||
The routine smoketest should be short: about 10 seconds wall time for launch and
|
||||
log check, followed by immediate teardown. If expected plugin log lines do not
|
||||
appear inside that window, treat that as a failure to investigate instead of
|
||||
stretching the timeout.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Run from the target BSManager instance directory, for example:
|
||||
|
||||
```sh
|
||||
cd "$HOME/.local/share/BSManager/BSInstances/1.44.1"
|
||||
```
|
||||
|
||||
- The instance should contain BSIPA (`IPA/`, `IPA.exe`, `winhttp.dll`, and
|
||||
`Plugins/`).
|
||||
- If those files are absent, the run can still produce Unity `Player.log`
|
||||
output, but it will not produce `Logs/_latest.log` or any IPA plugin-loading
|
||||
evidence. Run `plugin-helper bootstrap --instance <version>` before using
|
||||
this workflow to validate plugins.
|
||||
- On Plasma + Wayland, the shell needs working desktop session variables:
|
||||
`DISPLAY`, `WAYLAND_DISPLAY`, and `XDG_RUNTIME_DIR`. If the IDE terminal is
|
||||
missing them, copy values from the logged-in desktop session.
|
||||
|
||||
## Launch
|
||||
|
||||
Run the Proton launch in the foreground with a short watchdog. Backgrounding
|
||||
the Proton launch itself from an IDE terminal has been observed to exit early
|
||||
before Beat Saber writes useful `_latest.log` lines.
|
||||
|
||||
Important: `timeout` may stop the launcher wrapper without killing the full
|
||||
Beat Saber/Proton process tree. Prefer a watchdog that sleeps for the smoke
|
||||
window and then kills the game process by name.
|
||||
|
||||
```sh
|
||||
export SteamAppId=620980 SteamOverlayGameId=620980 SteamGameId=620980
|
||||
export WINEDLLOVERRIDES='winhttp=n,b'
|
||||
export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/BSManager/SharedContent/compatdata"
|
||||
export STEAM_COMPAT_INSTALL_PATH="$PWD"
|
||||
export STEAM_COMPAT_CLIENT_INSTALL_PATH="$HOME/.local/share/Steam"
|
||||
export STEAM_COMPAT_APP_ID=620980
|
||||
export SteamEnv=1
|
||||
export OXR_PARALLEL_VIEWS=1
|
||||
|
||||
(
|
||||
sleep 10
|
||||
pkill -TERM -f "[B]eat Saber.exe" || pkill -TERM -f "[B]eat Saber" || true
|
||||
sleep 2
|
||||
pkill -KILL -f "[B]eat Saber.exe" || pkill -KILL -f "[B]eat Saber" || true
|
||||
) &
|
||||
smoke_watchdog_pid=$!
|
||||
trap 'kill "$smoke_watchdog_pid" 2>/dev/null || true' EXIT
|
||||
|
||||
steam-run "$HOME/.local/share/Steam/steamapps/common/Proton - Experimental/proton" \
|
||||
run "$PWD/Beat Saber.exe" --no-yeet fpfc 2>&1 | tee /tmp/bs-smoke.log
|
||||
|
||||
wait "$smoke_watchdog_pid" 2>/dev/null || true
|
||||
trap - EXIT
|
||||
```
|
||||
|
||||
Useful launch arguments:
|
||||
|
||||
| Argument | Use |
|
||||
| --- | --- |
|
||||
| `--verbose` | Opens the BSIPA console window. |
|
||||
| `--debug` | Promotes debug logs to console output. |
|
||||
| `--trace` | Enables very noisy BSIPA/internal traces. |
|
||||
| `fpfc` | First-Person Flying Controller, useful for non-VR menu testing. |
|
||||
| `--auto_play` | Built-in autoplayer for gameplay checks. |
|
||||
|
||||
## Log Checks
|
||||
|
||||
In another terminal, or immediately after the launch returns:
|
||||
|
||||
```sh
|
||||
tail -F Logs/_latest.log
|
||||
```
|
||||
|
||||
For a batch install, check for:
|
||||
|
||||
- BSIPA startup reaches plugin loading.
|
||||
- the expected plugin names and versions appear in `Logs/_latest.log`.
|
||||
- there are no missing assembly or dependency-resolution failures.
|
||||
- there are no repeated unhandled exceptions from newly installed plugins.
|
||||
- the game reaches the main menu.
|
||||
|
||||
After the first successful launch, `plugin-helper bootstrap-check --instance
|
||||
<version>` should pass. Ordinary plugin plans for lockfiles that include BSIPA
|
||||
depend on that recorded bootstrap state and the latest IPA log.
|
||||
|
||||
For Setlist specifically:
|
||||
|
||||
```sh
|
||||
grep Setlist Logs/_latest.log
|
||||
```
|
||||
|
||||
Expected Setlist signals include `platformUserId=...`, playlist lines with
|
||||
`hasSyncUrl=...`, and `beatLeaderOwnerConfirmed=...`. When testing real playlist
|
||||
sync, adding a map to an owned BeatLeader-synced playlist should log an HTTP
|
||||
success line.
|
||||
|
||||
## Teardown
|
||||
|
||||
When the expected log lines are present before the watchdog fires, stop Beat
|
||||
Saber immediately from a second terminal instead of waiting for the smoke window
|
||||
to expire.
|
||||
|
||||
```sh
|
||||
pkill -TERM -f "[B]eat Saber.exe" || pkill -TERM -f "[B]eat Saber" || true
|
||||
sleep 2
|
||||
pkill -KILL -f "[B]eat Saber.exe" || pkill -KILL -f "[B]eat Saber" || true
|
||||
```
|
||||
|
||||
The bracketed pattern avoids matching the shell command that is running the
|
||||
cleanup. If Beat Saber still remains open, close the game window manually and
|
||||
then check for leftovers:
|
||||
|
||||
```sh
|
||||
ps -eo pid,ppid,stat,comm,args | rg -i '[B]eat Saber|[P]roton.*Beat Saber|[w]ineserver|[s]team-run'
|
||||
```
|
||||
|
||||
If the smoketest fails, kill leftover Beat Saber, Wine, or Proton processes
|
||||
before retrying, then inspect `/tmp/bs-smoke.log`, Unity `Player.log` in the
|
||||
Proton compatdata, and the timestamped files under `Logs/`.
|
||||
|
||||
## Plugin Development Notes From Setlist
|
||||
|
||||
These are relevant when `plugin-helper` installs locally built personal plugins.
|
||||
|
||||
- PC BSIPA plugins are .NET Framework class libraries loaded by BSIPA. Linux
|
||||
`dotnet build` output is CIL and can be loaded by the Proton game instance.
|
||||
- BSMT-style builds may copy the DLL directly into `<BeatSaberDir>/Plugins/`;
|
||||
use `-p:DisableCopyToPlugins=True` when a build should not mutate the game
|
||||
tree.
|
||||
- `BeatSaberDir` in project user files or hint paths should point at the exact
|
||||
BSManager instance being targeted.
|
||||
- If a plugin build produces a shipped artifact, bump the plugin version before
|
||||
the successful build so the IPA log line identifies the artifact under test.
|
||||
- Setlist depends on BeatLeader being installed and signed in, PlaylistManager,
|
||||
and BeatSaberPlaylistsLib. Its normal install target is `Plugins/Setlist.dll`.
|
||||
@@ -0,0 +1,302 @@
|
||||
# Beat Saber 1.44.1 Plugin Install and Verification
|
||||
|
||||
Tracking document for installing plugins into the Beat Saber 1.44.1 instance with
|
||||
`plugin-helper`, then verifying the game loads and the IPA log stays clean enough
|
||||
to continue.
|
||||
|
||||
Goal for this pass: get a working 1.44.1 plugin set. If a plugin blocks startup,
|
||||
breaks song loading, or produces serious IPA errors, omit it and record the
|
||||
failure. Do not fix incompatible plugins today.
|
||||
|
||||
## Source Baseline
|
||||
|
||||
Use `docs/notes/mods-used-in-1.40.8.md` as the starting inventory.
|
||||
|
||||
- 1.40.8 was installed through bs-manager plus some manual/private plugin drops.
|
||||
- 1.44.1 was installed through bs-manager for the game only.
|
||||
- 1.44.1 plugins should be installed through `plugin-helper`, not bs-manager.
|
||||
- BeatMods and GitHub release metadata may differ from the 1.40.8 set, so every
|
||||
selected plugin needs an explicit source/version recorded before install.
|
||||
- Prefer upstream GitHub releases as the artifact source for normal plugins.
|
||||
Use BeatMods as compatibility/dependency metadata by default, and as an
|
||||
artifact source only when the upstream artifact is inaccessible, the package is
|
||||
effectively BeatMods-only, or the package is a framework/library dependency
|
||||
such as the .NET assemblies.
|
||||
|
||||
## Current 1.44.1 Instance Observation
|
||||
|
||||
As of 2026-06-28, the BSManager-managed 1.44.1 instance at
|
||||
`~/.local/share/BSManager/BSInstances/1.44.1` appears to be an unpatched vanilla
|
||||
game tree:
|
||||
|
||||
- root `IPA/`, `IPA.exe`, `winhttp.dll`, `Libs/`, `Logs/`, and `UserData` are
|
||||
absent
|
||||
- root `Plugins/` exists only because it was created manually and is empty
|
||||
- the only pre-existing `Plugins` directory was `Beat Saber_Data/Plugins`, which
|
||||
contains Unity/runtime native DLLs and must not be treated as the BSIPA mod
|
||||
folder
|
||||
|
||||
By contrast, the Steam install for the same game version `1.44.1_20239` has
|
||||
BSIPA instrumentation and support files at the game root:
|
||||
|
||||
- `IPA/`, `IPA.exe`, `winhttp.dll`
|
||||
- `Libs/`
|
||||
- populated root `Plugins/`
|
||||
- `Logs/_latest.log`
|
||||
- `UserData/`
|
||||
|
||||
This means BSManager's mod installation step does more than place user-selected
|
||||
plugin DLLs. It also materializes the BSIPA loader and shared library substrate
|
||||
that make IPA logs and BSIPA plugin loading possible. `plugin-helper` needs to
|
||||
model that bootstrap layer separately from ordinary plugin batches.
|
||||
|
||||
The vanilla BSManager 1.44.1 launch did not produce an IPA log because BSIPA was
|
||||
not present. It did produce a Unity `Player.log` under BSManager's shared Proton
|
||||
compatdata and reached Steam/game initialization. So the missing bootstrap layer
|
||||
is not proven to be required for vanilla Beat Saber to execute, but it is
|
||||
required for the modded workflow and may affect the BSManager launch behavior we
|
||||
are trying to reproduce.
|
||||
|
||||
## Verification Loop
|
||||
|
||||
Use the canonical smoketest workflow in `docs/SMOKETEST.md`.
|
||||
|
||||
For each batch:
|
||||
|
||||
1. Back up or snapshot the current 1.44.1 plugin state.
|
||||
2. Install the selected batch with `plugin-helper`.
|
||||
3. Start Beat Saber 1.44.1 with the foreground `timeout 10` Proton/FPFC launch.
|
||||
4. Watch the IPA log during launch and first menu load.
|
||||
5. Load a known-good custom song if the batch affects songs, playlists, maps, or
|
||||
leaderboards.
|
||||
6. Record result in this document.
|
||||
7. If the game fails to load or logs serious plugin errors, remove the failing
|
||||
plugin or batch and retry.
|
||||
|
||||
Suggested log checks:
|
||||
|
||||
- startup reaches main menu
|
||||
- no plugin dependency resolution failures
|
||||
- no repeated unhandled exceptions
|
||||
- no missing assembly errors
|
||||
- no hard failures from BSIPA, SiraUtil, BSML, SongCore, or CustomJSONData
|
||||
- custom songs still enumerate
|
||||
- playlist and downloader UI still opens when relevant
|
||||
|
||||
## Status Legend
|
||||
|
||||
- <span style="color:#8b949e; font-weight:600">todo</span>: not attempted
|
||||
- <span style="color:#58a6ff; font-weight:600">planned</span>: source selected, ready to install
|
||||
- <span style="color:#bc8cff; font-weight:600">installed</span>: copied into the instance
|
||||
- <span style="color:#3fb950; font-weight:600">verified</span>: game launched and basic behavior checked
|
||||
- <span style="color:#d29922; font-weight:600">verified with warning</span>: game launched, but the log had a non-blocking warning to track
|
||||
- <span style="color:#f85149; font-weight:600">omitted</span>: skipped for this 1.44.1 pass due to failure or missing compatible release
|
||||
- <span style="color:#dbab79; font-weight:600">defer</span>: intentionally left for a later pass
|
||||
|
||||
## plugin-helper Work Needed
|
||||
|
||||
Track the tool work discovered while using it for 1.44.1.
|
||||
|
||||
| Item | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| Add or generate a 1.44.1 lockfile | <span style="color:#8b949e; font-weight:600">todo</span> | Need exact versions, sources, hashes, and target paths. |
|
||||
| Model BSIPA/bootstrap installation separately | <span style="color:#8b949e; font-weight:600">todo</span> | BSManager creates root `IPA/`, `IPA.exe`, `winhttp.dll`, `Libs/`, `Logs`, `UserData`, and root `Plugins/`. |
|
||||
| Resolve BeatMods dependency closure | <span style="color:#8b949e; font-weight:600">todo</span> | Use as metadata/advisory input even when downloading plugin artifacts from upstream GitHub. |
|
||||
| Install BeatMods library payloads into `Libs/` | <span style="color:#8b949e; font-weight:600">todo</span> | Include framework-library cases when required; these are likely exceptions to GitHub-preferred sourcing. |
|
||||
| Support local/private plugin payloads | <span style="color:#8b949e; font-weight:600">todo</span> | Needed for paid closed-source and manual plugins. |
|
||||
| Record install state for every copied file | <span style="color:#8b949e; font-weight:600">todo</span> | Required for rollback and clean omission testing. |
|
||||
| Add a batch install workflow or documented command sequence | <span style="color:#8b949e; font-weight:600">todo</span> | Useful for two-or-three-at-a-time validation. |
|
||||
| Add IPA log inspection helper | <span style="color:#8b949e; font-weight:600">todo</span> | Nice-to-have; manual log watching via `docs/SMOKETEST.md` is acceptable today. |
|
||||
|
||||
## Batch Plan
|
||||
|
||||
Install in small batches. Dependencies may be installed earlier than the plugin
|
||||
that made them necessary, but record that relationship when it is known.
|
||||
|
||||
### Batch 0: Game and Loader Baseline
|
||||
|
||||
Purpose: verify the clean 1.44.1 game and loader before adding gameplay mods.
|
||||
|
||||
| Plugin | Status | Source/version | Verification notes |
|
||||
| --- | --- | --- | --- |
|
||||
| BSIPA | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `nike4613/BeatSaber-IPA-Reloaded` tag `4.3.7`, asset `BSIPA-net472-x64.zip`; BeatMods version id 2561, zipHash `947774ef1010ff809ae05e345e269a90` | GitHub asset is byte-identical to the BeatMods CDN zip used for initial bootstrap. Smoketest produced `Logs/_latest.log`; IPA reported game version 1.44.1 and BSIPA 4.3.7. |
|
||||
|
||||
### Batch 1: Core Song Loading
|
||||
|
||||
Purpose: get custom song loading working before UI, leaderboard, or cosmetic
|
||||
mods are added.
|
||||
|
||||
| Plugin | Status | Source/version | Verification notes |
|
||||
| --- | --- | --- | --- |
|
||||
| SongCore | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 3.16.0, version id 2564, zipHash `0af9c0a03074c17ca15c1b667a0e30c8`; BeatMods preferred repo `Kylemc1413/SongCore` currently has no matching 3.16.0 GitHub release asset | IPA loaded SongCore; full song refresh loaded 2 songs from `CustomLevels`. Keep as BeatMods CDN fallback until a matching upstream asset is found. |
|
||||
| BeatSaberMarkupLanguage | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `monkeymanboy/BeatSaberMarkupLanguage` tag `v1.14.1`, asset `BeatSaberMarkupLanguage-v1.14.1+bs.1.41.1-RELEASE.zip`; BeatMods version id 2567, zipHash `46149d03f8549e07f2c88fefde4337b2` | GitHub asset is byte-identical to the BeatMods CDN zip used for initial bootstrap. IPA loaded BSML; font fallback warnings only. |
|
||||
| SiraUtil | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `Auros/SiraUtil` tag `v3.3.1`, asset `SiraUtil-v3.3.1+bs.1.42.0.zip`; BeatMods version id 2565, zipHash `ae14f7d3192a919d5d996c802fbde037` | GitHub asset is byte-identical to the BeatMods CDN zip used for initial bootstrap. IPA loaded SiraUtil and installed app/menu installers. |
|
||||
|
||||
### Batch 2: Custom Map Capabilities
|
||||
|
||||
Purpose: enable common map extensions after basic song loading is proven.
|
||||
|
||||
| Plugin | Status | Source/version | Verification notes |
|
||||
| --- | --- | --- | --- |
|
||||
| CustomJSONData | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `Aeroluna/CustomJSONData` tag `v2.6.8`, asset `CustomJSONData-2.6.8+1.40.0-bs1.40.0-7c2c32c.zip`; BeatMods version id 2327, zipHash `fed31638bbb678580ef760ec83cfd486` | GitHub asset is byte-identical to the BeatMods CDN zip. IPA loaded CustomJSONData 2.6.8+1.40.0; game reached main initialization; SongCore still loaded 2 custom songs. |
|
||||
| Heck | <span style="color:#f85149; font-weight:600">omitted</span> | No BeatMods verified 1.44.1 entry found on 2026-06-28 | Required by Chroma/NoodleExtensions/Vivify; skip until a compatible source is identified. |
|
||||
| Chroma | <span style="color:#f85149; font-weight:600">omitted</span> | No BeatMods verified 1.44.1 entry found on 2026-06-28 | Skip until Heck and a compatible Chroma source are identified. |
|
||||
| NoodleExtensions | <span style="color:#f85149; font-weight:600">omitted</span> | No BeatMods verified 1.44.1 entry found on 2026-06-28 | Skip until Heck and a compatible NoodleExtensions source are identified. |
|
||||
| Vivify | <span style="color:#f85149; font-weight:600">omitted</span> | No BeatMods verified 1.44.1 entry found on 2026-06-28 | Skip until Heck and a compatible Vivify source are identified. |
|
||||
|
||||
### Batch 3: Downloaders and Playlists
|
||||
|
||||
Purpose: restore in-game song discovery and playlist management.
|
||||
|
||||
| Plugin | Status | Source/version | Verification notes |
|
||||
| --- | --- | --- | --- |
|
||||
| BeatSaverDownloader | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 6.0.7, version id 2217, zipHash `a740c6e68a9b5d1dfda3cc8e81f7cf06`; BeatMods preferred repo `Top-Cat/BeatSaverDownloader` exposes no release assets through the GitHub releases API | IPA loaded BeatSaver Downloader 6.0.7 and started its internal webserver. Warning: it probed for missing `BetterSongList.dll` with IPA library-loader `CRITICAL` lines, then continued. |
|
||||
| PlaylistManager | <span style="color:#f85149; font-weight:600">omitted</span> | No BeatMods verified 1.44.1 entry found on 2026-06-28 | Skip until a compatible source is identified. |
|
||||
| BeatSaverUpdater | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `ibillingsley/BeatSaverUpdater` tag `1.2.11`, asset `BeatSaverUpdater-1.2.11-bs1.39.1-3698f98.zip`; BeatMods version id 2352, zipHash `d9ea8dd0cbaac66cbb02fa59a548e42b` | GitHub asset is byte-identical to the BeatMods CDN zip. IPA loaded BeatSaverUpdater 1.2.11. |
|
||||
| BeatSaverVoting | <span style="color:#f85149; font-weight:600">omitted</span> | No BeatMods verified 1.44.1 entry found on 2026-06-28 | Skip until a compatible source is identified. |
|
||||
| BeatSaberPlaylistsLib | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 1.7.2, version id 2175, zipHash `a3418b75ed7294a3856f3eca12bbd672`; BeatMods preferred repo `Meivyn/BeatSaberPlaylistsLib` exposes no release assets through the GitHub releases API | IPA loaded BeatSaberPlaylistsLib 1.7.2. |
|
||||
| BeatSaverSharp | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 3.4.5, version id 1831, zipHash `be37e13e93d9ac7da4efbdc3f514fa8f`; BeatMods preferred repo `lolPants/BeatSaverSharp` was inaccessible through the GitHub releases API | IPA loaded BeatSaverSharp 3.4.5. |
|
||||
| ScoreSaberSharp | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 0.1.0, version id 445, zipHash `8713168c598577ee7c73fa3cf0e26f5c`; BeatMods lists `scoresaber.com` rather than a GitHub release source | IPA loaded ScoreSaberSharp 0.1.0. Warning: bare manifest does not declare files. |
|
||||
| BS Utils | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 1.14.3, version id 2563, zipHash `918d13ac2821a3a17b2819f8861453e9`; BeatMods preferred repo `Kylemc1413/Beat-Saber-Utils` exposes no matching 1.14.3 GitHub release asset | IPA loaded BS Utils 1.14.3. |
|
||||
| Ini Parser | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 2.5.9, version id 1352, zipHash `5df74ad1c6b120fecdc615dd55f15b88` | IPA loaded INI Parser 2.5.9. |
|
||||
| ImageSharp | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 2.0.0, version id 1428, zipHash `b642fec88b0f84a0643ebd401d08da35` | IPA loaded ImageSharp 2.0.0. |
|
||||
| System.IO.Compression | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 4.6.57, version id 1763, zipHash `a4e9e26f61967e56168e08eecb01ab88` | IPA loaded System.IO.Compression 4.6.57. Warning: duplicate-library notice because the game also ships this assembly. |
|
||||
| System.IO.Compression.FileSystem | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 4.7.3056, version id 1762, zipHash `e19f6fd395d54de7bfcbbbe3084dea28` | IPA loaded System.IO.Compression.FileSystem 4.7.3056. |
|
||||
|
||||
### Batch 4: Leaderboards and Ranking
|
||||
|
||||
Purpose: add online leaderboard/ranking integrations after core song behavior is
|
||||
stable.
|
||||
|
||||
| Plugin | Status | Source/version | Verification notes |
|
||||
| --- | --- | --- | --- |
|
||||
| ScoreSaber | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify menu panel and song leaderboard. |
|
||||
| BeatLeader | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify menu panel and song leaderboard. |
|
||||
| LeaderboardCore | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Dependency for BeatLeader. |
|
||||
| AccSaber | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual/plugin-helper registry candidate. |
|
||||
| SongRankedBadge | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify song-list badges. |
|
||||
|
||||
### Batch 5: Practice and Gameplay Tweaks
|
||||
|
||||
Purpose: add small gameplay helpers two or three at a time.
|
||||
|
||||
| Plugin | Status | Source/version | Verification notes |
|
||||
| --- | --- | --- | --- |
|
||||
| IntroSkip | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify song start behavior. |
|
||||
| FailButton | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify practice/fail UI behavior. |
|
||||
| EasyOffset | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify settings UI opens. |
|
||||
| GottaGoFast | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify startup and song load. |
|
||||
| HitsoundTweaks | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify settings and audio behavior. |
|
||||
| KeepMyOverridesPls | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify startup only unless override test is easy. |
|
||||
| SoundReplacer | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify startup and settings. |
|
||||
| KeyRemapper | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual install candidate. |
|
||||
| SquatToBegin | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual install candidate. |
|
||||
| JDFixer | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual install candidate. |
|
||||
|
||||
### Batch 6: UI and Song Browser
|
||||
|
||||
Purpose: restore song-list, menu, and visualization conveniences.
|
||||
|
||||
| Plugin | Status | Source/version | Verification notes |
|
||||
| --- | --- | --- | --- |
|
||||
| BetterSongList | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify song browser opens and filters work. |
|
||||
| HitScoreVisualizer | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify settings UI and in-song display. |
|
||||
| DiTails | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify details panel. |
|
||||
| HideTheLogo | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify menu loads. |
|
||||
| SongChartVisualizer | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify chart UI. |
|
||||
| WhyIsThereNoLeaderboard | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Source TBD from 1.40.8 install. |
|
||||
| Setlist | <span style="color:#8b949e; font-weight:600">todo</span> | local build or release | Requires BeatLeader signed in, PlaylistManager, and BeatSaberPlaylistsLib; verify `Setlist` log lines. |
|
||||
| Custom Campaigns | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual install candidate. |
|
||||
|
||||
### Batch 7: Cosmetic, Camera, and Lighting
|
||||
|
||||
Purpose: add visual and stream-facing mods after functional mods are stable.
|
||||
|
||||
| Plugin | Status | Source/version | Verification notes |
|
||||
| --- | --- | --- | --- |
|
||||
| AdBlocker | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify startup. |
|
||||
| HighlightBombs | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify in-song visuals. |
|
||||
| PitchBlack | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Verify lighting behavior. |
|
||||
| Dimmer | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual install candidate. |
|
||||
| ReeCamera | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual install candidate. |
|
||||
| ReeSabers | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | Manual install candidate. |
|
||||
|
||||
### Batch 8: Paid or Closed-Source Plugins
|
||||
|
||||
Purpose: restore private plugin set only after public/dependency-heavy mods are
|
||||
known good.
|
||||
|
||||
| Plugin | Status | Source/version | Verification notes |
|
||||
| --- | --- | --- | --- |
|
||||
| BeatSaberPlus_Chat | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup and module UI. |
|
||||
| BeatSaberPlus_ChatEmoteRain | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
||||
| BeatSaberPlus_ChatIntegrations | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
||||
| BeatSaberPlus_ChatRequest | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
||||
| BeatSaberPlus_GameTweaker | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
||||
| BeatSaberPlus_MenuMusic | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
||||
| BeatSaberPlus_Multiplayer | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
||||
| BeatSaberPlus_NoteTweaker | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
||||
| BeatSaberPlus_SongChartVisualizer | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
||||
| BeatSaberPlus_SongOverlay | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
||||
| NalulunaMenu | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup and menu. |
|
||||
| NalulunaCounters | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify in-song counters. |
|
||||
| NalulunaLevelDetail | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify song detail panel. |
|
||||
| NalulunaSliceVisualizer | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify in-song visuals. |
|
||||
| NalulunaSongPreview | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify song preview. |
|
||||
| NalulunaMissIndicator | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify in-song visuals. |
|
||||
| NalulunaEnergy | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify in-song HUD. |
|
||||
| NalulunaFps | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify overlay. |
|
||||
| NalulunaPPCoin | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
||||
| NalulunaRewinder | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
||||
| NalulunaAvatars | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify avatar load. |
|
||||
| NalulunaShaders | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify dependency for Naluluna visuals. |
|
||||
| NalulunaSkybox | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify skybox sample manifest and skybox load. |
|
||||
| NalulunaUtils | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify dependency load. |
|
||||
|
||||
## Shared Libraries and Dependency Packages
|
||||
|
||||
These should be installed because selected plugins require them, not because they
|
||||
are user-facing features.
|
||||
|
||||
| Package | Status | Required by | Source/version | Verification notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| AssetBundleLoadingTools | <span style="color:#8b949e; font-weight:600">todo</span> | Vivify | TBD | Usually `Libs/`. |
|
||||
| BS Utils | <span style="color:#3fb950; font-weight:600">verified</span> | BeatSaverDownloader, BeatLeader | BeatMods 1.14.3, version id 2563, zipHash `918d13ac2821a3a17b2819f8861453e9` | IPA loaded BS Utils 1.14.3. |
|
||||
| CameraUtils | <span style="color:#8b949e; font-weight:600">todo</span> | Vivify | TBD | Verify no missing assembly errors. |
|
||||
| ImageSharp | <span style="color:#3fb950; font-weight:600">verified</span> | BeatSaberPlaylistsLib | BeatMods 2.0.0, version id 1428, zipHash `b642fec88b0f84a0643ebd401d08da35` | IPA loaded ImageSharp 2.0.0. |
|
||||
| Ini Parser | <span style="color:#3fb950; font-weight:600">verified</span> | BS Utils | BeatMods 2.5.9, version id 1352, zipHash `5df74ad1c6b120fecdc615dd55f15b88` | IPA loaded INI Parser 2.5.9. |
|
||||
| LookupID | <span style="color:#8b949e; font-weight:600">todo</span> | Chroma | TBD | Verify no missing assembly errors. |
|
||||
| OpenVR API | <span style="color:#8b949e; font-weight:600">todo</span> | TBD | TBD | Include only if a 1.44.1 dependency needs it. |
|
||||
| protobuf-net | <span style="color:#8b949e; font-weight:600">todo</span> | SongDetailsCache | TBD | Usually `Libs/`. |
|
||||
| SongDetailsCache | <span style="color:#8b949e; font-weight:600">todo</span> | BetterSongList, SongRankedBadge | TBD | Verify cache startup. |
|
||||
| System.IO.Compression | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatSaberPlaylistsLib, System.IO.Compression.FileSystem | BeatMods 4.6.57, version id 1763, zipHash `a4e9e26f61967e56168e08eecb01ab88` | IPA loaded System.IO.Compression 4.6.57; logged a duplicate-library notice because the game also ships this assembly. |
|
||||
| System.IO.Compression.FileSystem | <span style="color:#3fb950; font-weight:600">verified</span> | BeatSaverDownloader | BeatMods 4.7.3056, version id 1762, zipHash `e19f6fd395d54de7bfcbbbe3084dea28` | IPA loaded System.IO.Compression.FileSystem 4.7.3056. |
|
||||
| Dynamic Bone | <span style="color:#dbab79; font-weight:600">defer</span> | TBD | TBD | Include only if a selected 1.44.1 mod requires it. |
|
||||
| Final IK | <span style="color:#dbab79; font-weight:600">defer</span> | TBD | TBD | Include only if a selected 1.44.1 mod requires it. |
|
||||
|
||||
## Omitted Plugins
|
||||
|
||||
Record plugins skipped for this 1.44.1 pass. This is not a fix list for today.
|
||||
|
||||
| Plugin | Reason omitted | Evidence/log note | Follow-up |
|
||||
| --- | --- | --- | --- |
|
||||
| Heck | No BeatMods verified 1.44.1 entry found on 2026-06-28. | Not installed. | Revisit only with a compatible source. |
|
||||
| Chroma | No BeatMods verified 1.44.1 entry found on 2026-06-28. | Not installed. | Revisit after Heck is available. |
|
||||
| NoodleExtensions | No BeatMods verified 1.44.1 entry found on 2026-06-28. | Not installed. | Revisit after Heck is available. |
|
||||
| Vivify | No BeatMods verified 1.44.1 entry found on 2026-06-28. | Not installed. | Revisit after Heck is available. |
|
||||
| PlaylistManager | No BeatMods verified 1.44.1 entry found on 2026-06-28. | Not installed. | Revisit only with a compatible source. |
|
||||
| BeatSaverVoting | No BeatMods verified 1.44.1 entry found on 2026-06-28. | Not installed. | Revisit only with a compatible source. |
|
||||
|
||||
## Batch Results
|
||||
|
||||
| Batch | Date | Result | IPA log notes | Action |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 0 | 2026-06-28 | <span style="color:#3fb950; font-weight:600">verified</span> | BSIPA 4.3.7 installed and generated `Logs/_latest.log`. | Continue to dependency/plugin batches. |
|
||||
| 1 | 2026-06-28 | <span style="color:#3fb950; font-weight:600">verified</span> | BSML, SiraUtil, and SongCore loaded; SongCore loaded 2 custom songs. Warnings: older target game-version metadata, missing Windows fonts, missing `CustomWIPLevels/Cache/Info.dat`, and one built-in `Magic.wav` duration approximation. | Treat as acceptable bootstrap baseline. |
|
||||
| 2 | 2026-06-28 | <span style="color:#3fb950; font-weight:600">verified</span> | CustomJSONData loaded; startup reached main initialization; SongCore still loaded 2 custom songs. Warnings: older target game-version metadata, missing Windows fonts, and missing `CustomWIPLevels/Cache/Info.dat`. | Continue to downloader/playlist batch. |
|
||||
| 3 | 2026-06-28 | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatSaverDownloader, BeatSaverUpdater, BeatSaberPlaylistsLib, BeatSaverSharp, ScoreSaberSharp, BS Utils, Ini Parser, ImageSharp, and System.IO.Compression packages loaded; startup reached main initialization; SongCore still loaded 2 custom songs. Warning: BeatSaverDownloader probed for missing `BetterSongList.dll` with IPA library-loader `CRITICAL` lines, then continued and started its internal webserver. | Stop here per request; consider installing BetterSongList before deeper downloader UI testing. |
|
||||
| 4 | | <span style="color:#8b949e; font-weight:600">todo</span> | | |
|
||||
| 5 | | <span style="color:#8b949e; font-weight:600">todo</span> | | |
|
||||
| 6 | | <span style="color:#8b949e; font-weight:600">todo</span> | | |
|
||||
| 7 | | <span style="color:#8b949e; font-weight:600">todo</span> | | |
|
||||
| 8 | | <span style="color:#8b949e; font-weight:600">todo</span> | | |
|
||||
Executable
+184
@@ -0,0 +1,184 @@
|
||||
# 1.40.8 Mods in Use
|
||||
|
||||
Mods installed by bs-manager and not.
|
||||
|
||||
## bs-manager source of truth
|
||||
|
||||
bs-manager gets verified mods from BeatMods:
|
||||
|
||||
```text
|
||||
https://beatmods.com/api/mods?status=verified&gameVersion=1.40.8&gameName=BeatSaber&platform=steampc
|
||||
```
|
||||
|
||||
It downloads mod zips from `/cdn/mod/<zipHash>.zip`, resolves dependencies by BeatMods mod-version ids, and recognizes installed mods by MD5 hash lookup through `/api/hashlookup?hash=<md5>`.
|
||||
|
||||
For the mounted Windows install, `UserData/Disabled Mods.json` has an empty `DisabledModIds` array, so the files currently present in `Plugins/` and `Libs/` are enabled.
|
||||
|
||||
## Core
|
||||
- BSIPA https://github.com/nike4613/BeatSaber-IPA-Reloaded
|
||||
- SongCore https://github.com/Kylemc1413/SongCore
|
||||
|
||||
## Essential
|
||||
- BeatSaverDownloader https://github.com/Top-Cat/BeatSaverDownloader
|
||||
- BeatSaverVoting https://github.com/Top-Cat/BeatSaverVoting
|
||||
- PlaylistManager https://github.com/rithik-b/PlaylistManager
|
||||
- BeatSaverUpdater https://github.com/ibillingsley/BeatSaverUpdater
|
||||
- SiraLocalizer https://github.com/Auros/SiraLocalizer
|
||||
|
||||
## Cosmetic
|
||||
- AdBlocker https://github.com/JonnyVR1/AdBlocker
|
||||
- HighlightBombs https://github.com/Meivyn/HighlightBombs
|
||||
|
||||
## Library
|
||||
|
||||
bs-manager installs these mostly as dependency closure. BeatMods records dependencies by mod-version id, so install planning needs to resolve the selected mod, then recursively add each dependency version before downloading zips from `zipHash`.
|
||||
|
||||
- AssetBundleLoadingTools https://github.com/nicoco007/AssetBundleLoadingTools
|
||||
- Required by: Vivify.
|
||||
- BeatSaberMarkupLanguage https://github.com/monkeymanboy/BeatSaberMarkupLanguage
|
||||
- Required by: most UI/config mods here, including SongCore, SiraUtil, PlaylistManager, BeatSaverDownloader, Chroma, Vivify, ScoreSaber, and BeatLeader.
|
||||
- BeatSaberPlaylistsLib https://github.com/Meivyn/BeatSaberPlaylistsLib
|
||||
- Required by: PlaylistManager.
|
||||
- BeatSaverSharp https://github.com/lolPants/BeatSaverSharp
|
||||
- Required by: BeatSaverDownloader, BeatSaverUpdater, DiTails, PlaylistManager.
|
||||
- BS Utils https://github.com/Kylemc1413/Beat-Saber-Utils
|
||||
- Required by: BeatSaverDownloader, BeatSaverVoting, BeatLeader.
|
||||
- CameraUtils https://github.com/Reezonate/CameraUtils
|
||||
- Required by: Vivify.
|
||||
- CustomJSONData https://github.com/Aeroluna/CustomJSONData
|
||||
- Required by: Chroma, Heck, NoodleExtensions, Vivify.
|
||||
- Dynamic Bone https://assetstore.unity.com/packages/tools/animation/dynamic-bone-16743
|
||||
- BeatMods-managed library in this install, but no depender was found among the currently listed BeatMods-recognized mods.
|
||||
- Final IK https://assetstore.unity.com/packages/tools/animation/final-ik-14290
|
||||
- BeatMods-managed library in this install, but no depender was found among the currently listed BeatMods-recognized mods.
|
||||
- Heck https://github.com/Aeroluna/Heck
|
||||
- Required by: Chroma, NoodleExtensions, Vivify.
|
||||
- ImageSharp https://github.com/SixLabors/ImageSharp/
|
||||
- Required by: BeatSaberPlaylistsLib.
|
||||
- Ini Parser https://github.com/rickyah/ini-parser
|
||||
- Required by: BS Utils.
|
||||
- LeaderboardCore https://github.com/NSGolova/LeaderboardCore
|
||||
- Required by: BeatLeader.
|
||||
- LookupID https://github.com/Aeroluna/Heck
|
||||
- Required by: Chroma.
|
||||
- OpenVR API https://github.com/nicoco007/BeatSaber-OpenVR-API
|
||||
- Present in `Plugins/` as `OpenVRHelper.manifest`; no depender was found among the currently listed BeatMods-recognized mods.
|
||||
- protobuf-net https://github.com/protobuf-net/protobuf-net
|
||||
- Required by: SongDetailsCache.
|
||||
- ScoreSaberSharp
|
||||
- Required by: BeatSaverDownloader.
|
||||
- SiraUtil https://github.com/Auros/SiraUtil
|
||||
- Required by: most Sira/Auros-style mods here, including SongCore, PlaylistManager, SiraLocalizer, Chroma, NoodleExtensions, BeatLeader, ScoreSaber, and many UI/tweak mods.
|
||||
- SongDetailsCache https://github.com/kinsi55/BeatSaber_SongDetails
|
||||
- Required by: BetterSongList, SongRankedBadge.
|
||||
|
||||
When a library package is installed by bs-manager, the payload usually lands in `Libs/`; several packages also leave a `.manifest` marker in `Plugins/`. `plugin-helper` should treat both files as part of the dependency package's install state, not as separate user-selected plugins.
|
||||
|
||||
### .NET framework library assemblies
|
||||
|
||||
These are BeatMods `library` records that bs-manager installs into `Libs/`, not normal Beat Saber plugin repos with GitHub releases.
|
||||
|
||||
- System.IO.Compression https://github.com/mono/mono
|
||||
- BeatMods library id 304.
|
||||
- Provides stream compression/decompression classes.
|
||||
- Installed file: `Libs/System.IO.Compression.dll`.
|
||||
- System.IO.Compression.FileSystem https://github.com/microsoft/referencesource
|
||||
- BeatMods library id 303.
|
||||
- Provides the .NET Framework `ZipFile`/filesystem path helpers layered over `System.IO.Compression`.
|
||||
- Installed file: `Libs/System.IO.Compression.FileSystem.dll`.
|
||||
|
||||
For `plugin-helper` to reproduce bs-manager behavior, these should be modeled as special framework-library dependencies instead of GitHub-release plugins:
|
||||
|
||||
- The dependency solver should be able to select BeatMods library ids 303 and 304 when another mod requires them, even though the BeatMods records do not expose normal version/download metadata in the 1.40.8 query.
|
||||
- The installer should place the resolved DLLs in `Libs/`, never `Plugins/`.
|
||||
- The install state should record them like any other installed file, including source, target path, size, and hash, so uninstall/rollback stays deterministic.
|
||||
- The helper should not overwrite `Beat Saber_Data/Managed/System.IO.Compression*.dll`; those assemblies already exist in the game runtime and are a different size than the copies bs-manager put in `Libs/`.
|
||||
- If a reusable source cannot be derived from BeatMods metadata, the registry needs an explicit rule or vendored/cache source for these two DLLs rather than a vague `dot.net` URL.
|
||||
|
||||
## Practice
|
||||
- IntroSkip https://github.com/Loloppe/Intro-Skip
|
||||
- FailButton https://github.com/qe201020335/FailButton
|
||||
- NoodleExtensions https://github.com/Aeroluna/NoodleExtensions
|
||||
- Vivify https://github.com/Aeroluna/Vivify
|
||||
|
||||
## UI
|
||||
- HitScoreVisualizer https://github.com/ErisApps/HitScoreVisualizer
|
||||
- WhyIsThereNoLeaderboard
|
||||
- BetterSongList https://github.com/kinsi55/BeatSaber_BetterSongList
|
||||
- DiTails https://github.com/Auros/DiTails/
|
||||
- HideTheLogo https://github.com/TheBlackParrot/HideTheLogo
|
||||
- SongChartVisualizer https://github.com/NuggoDEV/SongChartVisualizer
|
||||
- SongRankedBadge https://github.com/qe201020335/SongRankedBadge
|
||||
|
||||
## Other
|
||||
- BeatLeader https://github.com/BeatLeader/beatleader-mod
|
||||
- ScoreSaber https://github.com/ScoreSaber/pc-mod
|
||||
|
||||
## Tweaks
|
||||
- EasyOffset https://github.com/Reezonate/EasyOffset
|
||||
- GottaGoFast https://github.com/kinsi55/CS_BeatSaber_GottaGoFast
|
||||
- HitsoundTweaks https://github.com/GalaxyMaster2/HitsoundTweaks
|
||||
- KeepMyOverridesPls https://github.com/qqrz997/KeepMyOverridesPls
|
||||
- SoundReplacer https://github.com/Meivyn/SoundReplacer
|
||||
|
||||
## Lighting
|
||||
- Chroma https://github.com/Aeroluna/Chroma
|
||||
- PitchBlack https://github.com/Loloppe/BeatSaber_PitchBlack/
|
||||
|
||||
## Paid closed source
|
||||
|
||||
### BeatSaberPlus
|
||||
- BeatSaberPlus_Chat (`BeatSaberPlus_Chat.dll`)
|
||||
- BeatSaberPlus_ChatEmoteRain (`BeatSaberPlus_ChatEmoteRain.dll`)
|
||||
- BeatSaberPlus_ChatIntegrations (`BeatSaberPlus_ChatIntegrations.dll`)
|
||||
- BeatSaberPlus_ChatRequest (`BeatSaberPlus_ChatRequest.dll`)
|
||||
- BeatSaberPlus_GameTweaker (`BeatSaberPlus_GameTweaker.dll`)
|
||||
- BeatSaberPlus_MenuMusic (`BeatSaberPlus_MenuMusic.dll`)
|
||||
- BeatSaberPlus_Multiplayer (`BeatSaberPlus_Multiplayer.dll`)
|
||||
- BeatSaberPlus_NoteTweaker (`BeatSaberPlus_NoteTweaker.dll`)
|
||||
- BeatSaberPlus_SongChartVisualizer (`BeatSaberPlus_SongChartVisualizer.dll`)
|
||||
- BeatSaberPlus_SongOverlay (`BeatSaberPlus_SongOverlay.dll`)
|
||||
|
||||
### Naluluna
|
||||
- NalulunaMenu (`NalulunaMenu.dll`)
|
||||
- NalulunaCounters (`NalulunaCounters.dll`)
|
||||
- NalulunaLevelDetail (`NalulunaLevelDetail.dll`)
|
||||
- NalulunaSliceVisualizer (`NalulunaSliceVisualizer.dll`)
|
||||
- NalulunaSongPreview (`NalulunaSongPreview.dll`)
|
||||
- NalulunaMissIndicator (`NalulunaMissIndicator.dll`)
|
||||
- NalulunaEnergy (`NalulunaEnergy.dll`)
|
||||
- NalulunaFps (`NalulunaFps.dll`)
|
||||
- NalulunaPPCoin (`NalulunaPPCoin.dll`)
|
||||
- NalulunaRewinder (`NalulunaRewinder.dll`)
|
||||
- NalulunaAvatars (`NalulunaAvatars.dll`)
|
||||
- NalulunaShaders (`NalulunaShaders.dll`)
|
||||
- NalulunaSkybox (`NalulunaSkybox.dll`, `NalulunaSkyboxSamples.manifest`)
|
||||
- NalulunaUtils (`NalulunaUtils.dll`)
|
||||
|
||||
These mods were installed manually, not from bs-manager.
|
||||
|
||||
- ScoreSaber
|
||||
- BeatLeader
|
||||
- AccSaber
|
||||
- ChatPlexSDK_BS
|
||||
- Dimmer
|
||||
- DiTails
|
||||
- HideTheLogo
|
||||
- HitsoundTweaks
|
||||
- PitchBlack
|
||||
- ReeCamera
|
||||
- ReeSabers
|
||||
- SoundReplacer
|
||||
- BetterSongList
|
||||
- Setlist
|
||||
- SongChartVisualizer
|
||||
- SongRankedBadge
|
||||
- Chroma
|
||||
- EasyOffset
|
||||
- Custom Campaigns
|
||||
- JDFixer
|
||||
- KeepMyOverridesPls
|
||||
- GottaGoFast
|
||||
- KeyRemapper
|
||||
- SquatToBegin
|
||||
- wipbot
|
||||
@@ -0,0 +1,134 @@
|
||||
beat_saber_version = "1.44.1"
|
||||
instance = "1.44.1"
|
||||
|
||||
[[plugins]]
|
||||
id = "bsipa"
|
||||
repo = "nike4613/BeatSaber-IPA-Reloaded"
|
||||
tag = "4.3.7"
|
||||
asset = "BSIPA-net472-x64.zip"
|
||||
sha256 = "899b8a1dda91935bd5c19a211fa48e44e32f7be9ab82b6fc796709c753b7b2bc"
|
||||
install_strategy = "root-zip"
|
||||
reason = "BeatMods verified BSIPA 4.3.7 for Beat Saber 1.44.1 as version id 2561, zipHash 947774ef1010ff809ae05e345e269a90. GitHub asset is byte-identical to the BeatMods CDN zip used for initial bootstrap."
|
||||
|
||||
[[plugins]]
|
||||
id = "beatsabermarkuplanguage"
|
||||
repo = "monkeymanboy/BeatSaberMarkupLanguage"
|
||||
tag = "v1.14.1"
|
||||
asset = "BeatSaberMarkupLanguage-v1.14.1+bs.1.41.1-RELEASE.zip"
|
||||
sha256 = "816613459853955e2b7c02123ed42f8e5bdbd946cc774fd5fcc4e80a6a09120a"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "BeatMods verified BeatSaberMarkupLanguage 1.14.1 for Beat Saber 1.44.1 as version id 2567, zipHash 46149d03f8549e07f2c88fefde4337b2. GitHub asset is byte-identical to the BeatMods CDN zip used for initial bootstrap."
|
||||
|
||||
[[plugins]]
|
||||
id = "sirautil"
|
||||
repo = "Auros/SiraUtil"
|
||||
tag = "v3.3.1"
|
||||
asset = "SiraUtil-v3.3.1+bs.1.42.0.zip"
|
||||
sha256 = "2253e5c31324e1e01b23480acba5ceee6b67eb8f16a079f58bc2f6ce7b12051f"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "BeatMods verified SiraUtil 3.3.1 for Beat Saber 1.44.1 as version id 2565, zipHash ae14f7d3192a919d5d996c802fbde037. GitHub asset is byte-identical to the BeatMods CDN zip used for initial bootstrap."
|
||||
|
||||
[[plugins]]
|
||||
id = "songcore"
|
||||
repo = "Kylemc1413/SongCore"
|
||||
tag = "beatmods-3.16.0"
|
||||
asset = "SongCore-3.16.0.zip"
|
||||
sha256 = "f9be5d66426d795c6c8af2a4da0150a298d2d6a65fbabcab316d379088a40019"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "BeatMods verified SongCore 3.16.0 for Beat Saber 1.44.1 as version id 2564, zipHash 0af9c0a03074c17ca15c1b667a0e30c8. BeatMods points to Kylemc1413/SongCore, but that GitHub repo currently exposes releases only through 3.10.4, so this remains a BeatMods CDN fallback until a matching upstream release artifact is found."
|
||||
|
||||
[[plugins]]
|
||||
id = "customjsondata"
|
||||
repo = "Aeroluna/CustomJSONData"
|
||||
tag = "v2.6.8"
|
||||
asset = "CustomJSONData-2.6.8+1.40.0-bs1.40.0-7c2c32c.zip"
|
||||
sha256 = "30555c77485d2837bd294608fe4e23c34415b89413997be67a6150318090b216"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "BeatMods verified CustomJSONData 2.6.8+1.40.0 for Beat Saber 1.44.1 as version id 2327, zipHash fed31638bbb678580ef760ec83cfd486. GitHub asset is byte-identical to the BeatMods CDN zip."
|
||||
|
||||
[[plugins]]
|
||||
id = "iniparser"
|
||||
repo = "rickyah/ini-parser"
|
||||
tag = "beatmods-2.5.9"
|
||||
asset = "IniParser-2.5.9.zip"
|
||||
sha256 = "b761293bea73ff3cb9998f404594aade617638aba4318203320189baf3449ff1"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "BeatMods verified Ini Parser 2.5.9 for Beat Saber 1.44.1 as version id 1352, zipHash 5df74ad1c6b120fecdc615dd55f15b88. Use BeatMods CDN as a framework/library dependency payload."
|
||||
|
||||
[[plugins]]
|
||||
id = "bs-utils"
|
||||
repo = "Kylemc1413/Beat-Saber-Utils"
|
||||
tag = "beatmods-1.14.3"
|
||||
asset = "BSUtils-1.14.3.zip"
|
||||
sha256 = "b450fa4561da5b5ad834a3dcf6f127c2d73e5f971aa6c986514a6b185d68edfe"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "BeatMods verified BS Utils 1.14.3 for Beat Saber 1.44.1 as version id 2563, zipHash 918d13ac2821a3a17b2819f8861453e9. Upstream GitHub releases do not expose a matching 1.14.3 asset, so this remains a BeatMods CDN fallback."
|
||||
|
||||
[[plugins]]
|
||||
id = "system-io-compression"
|
||||
tag = "beatmods-4.6.57"
|
||||
asset = "System.IO.Compression-4.6.57.zip"
|
||||
sha256 = "9067ddaf52c077f38a27ace6180a8134ceaaf1ecba752d08de81b4cc0c13aa43"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "BeatMods verified System.IO.Compression 4.6.57 for Beat Saber 1.44.1 as version id 1763, zipHash a4e9e26f61967e56168e08eecb01ab88. Use BeatMods CDN as a framework/library dependency payload."
|
||||
|
||||
[[plugins]]
|
||||
id = "system-io-compression-filesystem"
|
||||
tag = "beatmods-4.7.3056"
|
||||
asset = "System.IO.Compression.FileSystem-4.7.3056.zip"
|
||||
sha256 = "1b6f7a33a69980344717a37240ed80b022903a52968d4968f307cf754e3a03f7"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "BeatMods verified System.IO.Compression.FileSystem 4.7.3056 for Beat Saber 1.44.1 as version id 1762, zipHash e19f6fd395d54de7bfcbbbe3084dea28. Use BeatMods CDN as a framework/library dependency payload."
|
||||
|
||||
[[plugins]]
|
||||
id = "imagesharp"
|
||||
repo = "SixLabors/ImageSharp"
|
||||
tag = "beatmods-2.0.0"
|
||||
asset = "ImageSharp-2.0.0.zip"
|
||||
sha256 = "b2bf6d195f25e1298199a389e381ab42f163b284105ae6181259d74f2528301b"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "BeatMods verified ImageSharp 2.0.0 for Beat Saber 1.44.1 as version id 1428, zipHash b642fec88b0f84a0643ebd401d08da35. Use BeatMods CDN as a framework/library dependency payload."
|
||||
|
||||
[[plugins]]
|
||||
id = "beatsaberplaylistslib"
|
||||
repo = "Meivyn/BeatSaberPlaylistsLib"
|
||||
tag = "beatmods-1.7.2"
|
||||
asset = "BeatSaberPlaylistsLib-1.7.2.zip"
|
||||
sha256 = "dfb26cf90cb73834c405727418c0121d8546cad0403abd35a09437dab766bd32"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "BeatMods verified BeatSaberPlaylistsLib 1.7.2 for Beat Saber 1.44.1 as version id 2175, zipHash a3418b75ed7294a3856f3eca12bbd672. Upstream GitHub exposes no release assets through the releases API, so this remains a BeatMods CDN fallback."
|
||||
|
||||
[[plugins]]
|
||||
id = "beatsaversharp"
|
||||
repo = "lolPants/BeatSaverSharp"
|
||||
tag = "beatmods-3.4.5"
|
||||
asset = "BeatSaverSharp-3.4.5.zip"
|
||||
sha256 = "f5f37b27438e9b2fa1d9fbdf51a4f015f44ae04979cbdd9b90f6ae18583a6911"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "BeatMods verified BeatSaverSharp 3.4.5 for Beat Saber 1.44.1 as version id 1831, zipHash be37e13e93d9ac7da4efbdc3f514fa8f. BeatMods upstream URL returned inaccessible via the GitHub releases API, so this remains a BeatMods CDN fallback."
|
||||
|
||||
[[plugins]]
|
||||
id = "scoresabersharp"
|
||||
tag = "beatmods-0.1.0"
|
||||
asset = "ScoreSaberSharp-0.1.0.zip"
|
||||
sha256 = "7f30be996f8f0e997f2d848e34de1d03ef6dc744ffc32d3fe505881ca22c6cd3"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "BeatMods verified ScoreSaberSharp 0.1.0 for Beat Saber 1.44.1 as version id 445, zipHash 8713168c598577ee7c73fa3cf0e26f5c. BeatMods lists scoresaber.com rather than a GitHub release source, so this remains a BeatMods CDN fallback."
|
||||
|
||||
[[plugins]]
|
||||
id = "beatsaverdownloader"
|
||||
repo = "Top-Cat/BeatSaverDownloader"
|
||||
tag = "beatmods-6.0.7"
|
||||
asset = "BeatSaverDownloader-6.0.7.zip"
|
||||
sha256 = "4108b11eae11d09f8c4e838dde1dc36108f1f3fd4fff31b951e7c551fa59f5f1"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "BeatMods verified BeatSaverDownloader 6.0.7 for Beat Saber 1.44.1 as version id 2217, zipHash a740c6e68a9b5d1dfda3cc8e81f7cf06. Upstream GitHub exposes no release assets through the releases API, so this remains a BeatMods CDN fallback."
|
||||
|
||||
[[plugins]]
|
||||
id = "beatsaverupdater"
|
||||
repo = "ibillingsley/BeatSaverUpdater"
|
||||
tag = "1.2.11"
|
||||
asset = "BeatSaverUpdater-1.2.11-bs1.39.1-3698f98.zip"
|
||||
sha256 = "6237da05cbc044e99211cb0e569c917fe61bb0da25e42c61a6d438371548ebba"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "BeatMods verified BeatSaverUpdater 1.2.11 for Beat Saber 1.44.1 as version id 2352, zipHash d9ea8dd0cbaac66cbb02fa59a548e42b. GitHub asset is byte-identical to the BeatMods CDN zip."
|
||||
@@ -21,3 +21,267 @@ repo = "not-dexter/accsaber-reloaded-plugin"
|
||||
asset_patterns = ["1.40.8.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "leaderboard"
|
||||
|
||||
[[plugins]]
|
||||
id = "bsipa"
|
||||
name = "BSIPA"
|
||||
repo = "nike4613/BeatSaber-IPA-Reloaded"
|
||||
asset_patterns = ["BSIPA-net472-x64.zip"]
|
||||
install_strategy = "root-zip"
|
||||
category = "core"
|
||||
|
||||
[[plugins]]
|
||||
id = "beatsabermarkuplanguage"
|
||||
name = "BeatSaberMarkupLanguage"
|
||||
repo = "monkeymanboy/BeatSaberMarkupLanguage"
|
||||
asset_patterns = ["*RELEASE.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "library"
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "bsipa"
|
||||
constraint = ">=4.3.7"
|
||||
required = true
|
||||
|
||||
[[plugins]]
|
||||
id = "sirautil"
|
||||
name = "SiraUtil"
|
||||
repo = "Auros/SiraUtil"
|
||||
asset_patterns = ["SiraUtil-*.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "library"
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "bsipa"
|
||||
constraint = ">=4.3.7"
|
||||
required = true
|
||||
|
||||
[[plugins]]
|
||||
id = "songcore"
|
||||
name = "SongCore"
|
||||
repo = "Kylemc1413/SongCore"
|
||||
asset_patterns = ["SongCore-*.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "core"
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "bsipa"
|
||||
constraint = ">=4.3.7"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "beatsabermarkuplanguage"
|
||||
constraint = ">=1.14.1"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "sirautil"
|
||||
constraint = ">=3.3.1"
|
||||
required = true
|
||||
|
||||
[[plugins]]
|
||||
id = "customjsondata"
|
||||
name = "CustomJSONData"
|
||||
repo = "Aeroluna/CustomJSONData"
|
||||
asset_patterns = ["CustomJSONData-*.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "library"
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "bsipa"
|
||||
constraint = ">=4.3.7"
|
||||
required = true
|
||||
|
||||
[[plugins]]
|
||||
id = "iniparser"
|
||||
name = "Ini Parser"
|
||||
repo = "rickyah/ini-parser"
|
||||
asset_patterns = ["IniParser-*.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "library"
|
||||
|
||||
[[plugins]]
|
||||
id = "bs-utils"
|
||||
name = "BS Utils"
|
||||
repo = "Kylemc1413/Beat-Saber-Utils"
|
||||
asset_patterns = ["BSUtils-*.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "library"
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "iniparser"
|
||||
constraint = ">=2.5.9"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "bsipa"
|
||||
constraint = ">=4.3.7"
|
||||
required = true
|
||||
|
||||
[[plugins]]
|
||||
id = "system-io-compression"
|
||||
name = "System.IO.Compression"
|
||||
asset_patterns = ["System.IO.Compression-*.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "library"
|
||||
|
||||
[[plugins]]
|
||||
id = "system-io-compression-filesystem"
|
||||
name = "System.IO.Compression.FileSystem"
|
||||
asset_patterns = ["System.IO.Compression.FileSystem-*.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "library"
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "system-io-compression"
|
||||
constraint = ">=4.6.57"
|
||||
required = true
|
||||
|
||||
[[plugins]]
|
||||
id = "imagesharp"
|
||||
name = "ImageSharp"
|
||||
repo = "SixLabors/ImageSharp"
|
||||
asset_patterns = ["ImageSharp-*.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "library"
|
||||
|
||||
[[plugins]]
|
||||
id = "beatsaberplaylistslib"
|
||||
name = "BeatSaberPlaylistsLib"
|
||||
repo = "Meivyn/BeatSaberPlaylistsLib"
|
||||
asset_patterns = ["BeatSaberPlaylistsLib-*.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "library"
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "songcore"
|
||||
constraint = ">=3.16.0"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "imagesharp"
|
||||
constraint = ">=2.0.0"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "system-io-compression"
|
||||
constraint = ">=4.6.57"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "bsipa"
|
||||
constraint = ">=4.3.7"
|
||||
required = true
|
||||
|
||||
[[plugins]]
|
||||
id = "beatsaversharp"
|
||||
name = "BeatSaverSharp"
|
||||
repo = "lolPants/BeatSaverSharp"
|
||||
asset_patterns = ["BeatSaverSharp-*.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "library"
|
||||
|
||||
[[plugins]]
|
||||
id = "scoresabersharp"
|
||||
name = "ScoreSaberSharp"
|
||||
asset_patterns = ["ScoreSaberSharp-*.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "library"
|
||||
|
||||
[[plugins]]
|
||||
id = "beatsaverdownloader"
|
||||
name = "BeatSaverDownloader"
|
||||
repo = "Top-Cat/BeatSaverDownloader"
|
||||
asset_patterns = ["BeatSaverDownloader-*.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "downloader"
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "bs-utils"
|
||||
constraint = ">=1.14.3"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "beatsabermarkuplanguage"
|
||||
constraint = ">=1.14.1"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "bsipa"
|
||||
constraint = ">=4.3.7"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "scoresabersharp"
|
||||
constraint = ">=0.1.0"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "system-io-compression-filesystem"
|
||||
constraint = ">=4.7.3056"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "songcore"
|
||||
constraint = ">=3.16.0"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "beatsaversharp"
|
||||
constraint = ">=3.4.5"
|
||||
required = true
|
||||
|
||||
[[plugins]]
|
||||
id = "beatsaverupdater"
|
||||
name = "BeatSaverUpdater"
|
||||
repo = "ibillingsley/BeatSaverUpdater"
|
||||
asset_patterns = ["BeatSaverUpdater-*.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "downloader"
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "bsipa"
|
||||
constraint = ">=4.3.7"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "beatsabermarkuplanguage"
|
||||
constraint = ">=1.14.1"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "songcore"
|
||||
constraint = ">=3.16.0"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "sirautil"
|
||||
constraint = ">=3.3.1"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "beatsaversharp"
|
||||
constraint = ">=3.4.5"
|
||||
required = true
|
||||
|
||||
[[plugins]]
|
||||
id = "setlist"
|
||||
name = "Setlist"
|
||||
asset_patterns = ["Setlist.dll"]
|
||||
install_strategy = "dll-to-plugins"
|
||||
category = "ui"
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "beatleader"
|
||||
constraint = ">=0.9.0"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "playlistmanager"
|
||||
constraint = ">=1.7.0"
|
||||
required = true
|
||||
|
||||
[[plugins.dependencies]]
|
||||
id = "beatsaberplaylistslib"
|
||||
constraint = ">=1.7.0"
|
||||
required = true
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from .bsipa import BSIPA_PLUGIN_ID, check_bsipa_health
|
||||
from .fsutil import sha256_file
|
||||
from .installer import apply_plan
|
||||
from .models import LockedPlugin, Lockfile, Registry
|
||||
from .planner import create_plan
|
||||
from .scanner import scan_bootstrap_files
|
||||
from .state import bootstrap_state_path, plugin_downloads_dir, save_bootstrap_state
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _default_proton() -> Path:
|
||||
return Path.home() / ".local/share/Steam/steamapps/common/Proton - Experimental/proton"
|
||||
|
||||
|
||||
def _proton_env(instance_path: Path) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"SteamAppId": "620980",
|
||||
"SteamOverlayGameId": "620980",
|
||||
"SteamGameId": "620980",
|
||||
"WINEDLLOVERRIDES": "winhttp=n,b",
|
||||
"STEAM_COMPAT_DATA_PATH": str(Path.home() / ".local/share/BSManager/SharedContent/compatdata"),
|
||||
"STEAM_COMPAT_INSTALL_PATH": str(instance_path),
|
||||
"STEAM_COMPAT_CLIENT_INSTALL_PATH": str(Path.home() / ".local/share/Steam"),
|
||||
"STEAM_COMPAT_APP_ID": "620980",
|
||||
"SteamEnv": "1",
|
||||
}
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
def _files_delta(before: list[dict[str, Any]], after: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
||||
before_by_path = {item["path"]: item for item in before}
|
||||
after_by_path = {item["path"]: item for item in after}
|
||||
created = [after_by_path[path] for path in sorted(after_by_path.keys() - before_by_path.keys())]
|
||||
removed = [before_by_path[path] for path in sorted(before_by_path.keys() - after_by_path.keys())]
|
||||
mutated = [
|
||||
{
|
||||
"path": path,
|
||||
"before": before_by_path[path],
|
||||
"after": after_by_path[path],
|
||||
}
|
||||
for path in sorted(before_by_path.keys() & after_by_path.keys())
|
||||
if before_by_path[path].get("sha256") != after_by_path[path].get("sha256")
|
||||
]
|
||||
return {"created": created, "mutated": mutated, "removed": removed}
|
||||
|
||||
|
||||
def _github_headers() -> dict[str, str]:
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "plugin-helper",
|
||||
}
|
||||
token = os.environ.get("GITHUB_TOKEN")
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers
|
||||
|
||||
|
||||
def _fetch_github_release_asset(locked: LockedPlugin, destination: Path) -> dict[str, Any]:
|
||||
if not locked.repo or not locked.tag or not locked.asset:
|
||||
raise ValueError("locked BSIPA entry needs repo, tag, and asset to fetch its archive")
|
||||
release_url = f"https://api.github.com/repos/{locked.repo}/releases/tags/{locked.tag}"
|
||||
request = Request(release_url, headers=_github_headers())
|
||||
with urlopen(request, timeout=20) as response:
|
||||
release = response.read().decode("utf-8")
|
||||
|
||||
import json
|
||||
|
||||
data = json.loads(release)
|
||||
assets = data.get("assets", [])
|
||||
selected = next((asset for asset in assets if asset.get("name") == locked.asset), None)
|
||||
if not selected:
|
||||
raise FileNotFoundError(f"{locked.id}: release {locked.tag} does not contain asset {locked.asset}")
|
||||
download_url = selected.get("browser_download_url")
|
||||
if not download_url:
|
||||
raise ValueError(f"{locked.id}: GitHub asset has no browser_download_url")
|
||||
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
asset_request = Request(download_url, headers={"User-Agent": "plugin-helper"})
|
||||
with urlopen(asset_request, timeout=60) as response:
|
||||
destination.write_bytes(response.read())
|
||||
actual_sha = sha256_file(destination)
|
||||
if locked.sha256 and actual_sha != locked.sha256:
|
||||
destination.unlink(missing_ok=True)
|
||||
raise ValueError(f"{locked.id}: fetched asset sha256 mismatch")
|
||||
return {
|
||||
"repo": locked.repo,
|
||||
"tag": locked.tag,
|
||||
"asset": locked.asset,
|
||||
"url": download_url,
|
||||
"path": str(destination),
|
||||
"sha256": actual_sha,
|
||||
}
|
||||
|
||||
|
||||
def fetch_locked_bsipa_archive(lockfile: Lockfile, state_root: Path) -> dict[str, Any]:
|
||||
locked = next((plugin for plugin in lockfile.plugins if plugin.id == BSIPA_PLUGIN_ID), None)
|
||||
if not locked:
|
||||
raise ValueError("lockfile does not include a bsipa entry")
|
||||
if not locked.asset:
|
||||
raise ValueError("locked BSIPA entry has no asset")
|
||||
destination = plugin_downloads_dir(state_root, lockfile.instance, BSIPA_PLUGIN_ID) / locked.asset
|
||||
if destination.is_file():
|
||||
actual_sha = sha256_file(destination)
|
||||
if locked.sha256 and actual_sha != locked.sha256:
|
||||
raise ValueError(f"{locked.id}: existing asset sha256 mismatch: {destination}")
|
||||
return {
|
||||
"asset": locked.asset,
|
||||
"path": str(destination),
|
||||
"sha256": actual_sha,
|
||||
"cached": True,
|
||||
}
|
||||
result = _fetch_github_release_asset(locked, destination)
|
||||
result["cached"] = False
|
||||
return result
|
||||
|
||||
|
||||
def _run_ipa(
|
||||
*,
|
||||
command: list[str],
|
||||
instance_path: Path,
|
||||
timeout_seconds: int,
|
||||
) -> dict[str, Any]:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=instance_path,
|
||||
env=_proton_env(instance_path),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=timeout_seconds)
|
||||
timed_out = False
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
stdout, stderr = process.communicate()
|
||||
return {
|
||||
"returncode": process.returncode,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"timedOut": timed_out,
|
||||
"timeoutSeconds": timeout_seconds,
|
||||
}
|
||||
|
||||
|
||||
def run_bootstrap(
|
||||
*,
|
||||
instance: str,
|
||||
instance_path: Path,
|
||||
beat_saber_version: str,
|
||||
registry: Registry,
|
||||
lockfile: Lockfile,
|
||||
state_root: Path,
|
||||
repo_root: Path,
|
||||
proton: Path | None = None,
|
||||
progress: Callable[[str], None] | None = None,
|
||||
ipa_timeout_seconds: int = 120,
|
||||
) -> dict[str, Any]:
|
||||
tell = progress or (lambda _message: None)
|
||||
tell("Fetching locked BSIPA archive")
|
||||
fetched = fetch_locked_bsipa_archive(lockfile, state_root)
|
||||
if fetched.get("cached"):
|
||||
tell(f"Using cached BSIPA archive: {fetched['path']}")
|
||||
else:
|
||||
tell(f"Downloaded BSIPA archive: {fetched['path']}")
|
||||
|
||||
tell("Scanning bootstrap files before install")
|
||||
before = scan_bootstrap_files(instance_path)
|
||||
tell("Creating BSIPA bootstrap plan")
|
||||
plan, plan_path = create_plan(
|
||||
instance=instance,
|
||||
instance_path=instance_path,
|
||||
beat_saber_version=beat_saber_version,
|
||||
registry=registry,
|
||||
lockfile=lockfile,
|
||||
state_root=state_root,
|
||||
repo_root=repo_root,
|
||||
selected={BSIPA_PLUGIN_ID},
|
||||
require_bootstrap=False,
|
||||
)
|
||||
if not plan["changes"]:
|
||||
raise ValueError("BSIPA bootstrap plan has no changes")
|
||||
|
||||
tell(f"Applying BSIPA bootstrap plan with {len(plan['changes'])} file changes")
|
||||
apply_result = apply_plan(plan, state_root)
|
||||
|
||||
ipa = instance_path / "IPA.exe"
|
||||
if not ipa.is_file():
|
||||
raise FileNotFoundError(f"BSIPA archive did not install IPA.exe: {ipa}")
|
||||
|
||||
proton_path = proton or _default_proton()
|
||||
if not proton_path.is_file():
|
||||
raise FileNotFoundError(f"Proton executable not found: {proton_path}")
|
||||
|
||||
command = [str(proton_path), "run", str(ipa), "-n"]
|
||||
tell(f"Running IPA.exe -n through Proton; timeout {ipa_timeout_seconds}s")
|
||||
completed = _run_ipa(
|
||||
command=command,
|
||||
instance_path=instance_path,
|
||||
timeout_seconds=ipa_timeout_seconds,
|
||||
)
|
||||
tell("Scanning bootstrap files after IPA.exe -n")
|
||||
after = scan_bootstrap_files(instance_path)
|
||||
delta = _files_delta(before, after)
|
||||
state = {
|
||||
"schemaVersion": 1,
|
||||
"instance": instance,
|
||||
"beatSaberVersion": beat_saber_version,
|
||||
"bootstrappedAt": _now_iso(),
|
||||
"plugin": BSIPA_PLUGIN_ID,
|
||||
"archive": fetched,
|
||||
"planPath": str(plan_path),
|
||||
"applied": apply_result["applied"],
|
||||
"proton": str(proton_path),
|
||||
"command": command,
|
||||
"ipaExitCode": completed["returncode"],
|
||||
"ipaTimedOut": completed["timedOut"],
|
||||
"ipaTimeoutSeconds": completed["timeoutSeconds"],
|
||||
"ipaStdout": completed["stdout"][-20000:],
|
||||
"ipaStderr": completed["stderr"][-20000:],
|
||||
"files": after,
|
||||
"delta": delta,
|
||||
"health": {},
|
||||
}
|
||||
save_bootstrap_state(state_root, instance, state)
|
||||
state["health"] = check_bsipa_health(instance_path, state_root, instance)
|
||||
save_bootstrap_state(state_root, instance, state)
|
||||
state["statePath"] = str(bootstrap_state_path(state_root, instance))
|
||||
if completed["timedOut"]:
|
||||
raise TimeoutError(f"IPA.exe -n timed out after {ipa_timeout_seconds}s; state written to {state['statePath']}")
|
||||
if completed["returncode"] != 0:
|
||||
raise RuntimeError(f"IPA.exe -n failed with exit code {completed['returncode']}; state written to {state['statePath']}")
|
||||
return state
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .fsutil import sha256_file
|
||||
from .state import bootstrap_state_path, load_bootstrap_state
|
||||
|
||||
|
||||
BSIPA_PLUGIN_ID = "bsipa"
|
||||
|
||||
|
||||
def latest_log_path(instance_path: Path) -> Path:
|
||||
return instance_path / "Logs" / "_latest.log"
|
||||
|
||||
|
||||
def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) -> dict[str, Any]:
|
||||
state = load_bootstrap_state(state_root, instance)
|
||||
messages: list[str] = []
|
||||
|
||||
required = ["IPA.exe", "winhttp.dll"]
|
||||
for rel in required:
|
||||
if not (instance_path / rel).is_file():
|
||||
messages.append(f"missing {rel}")
|
||||
for rel in ("IPA", "Libs"):
|
||||
if not (instance_path / rel).is_dir():
|
||||
messages.append(f"missing {rel}/")
|
||||
|
||||
log_path = latest_log_path(instance_path)
|
||||
if not log_path.is_file():
|
||||
messages.append("missing Logs/_latest.log")
|
||||
else:
|
||||
text = log_path.read_text(encoding="utf-8", errors="replace")
|
||||
if "Beat Saber IPA (BSIPA):" not in text and "Beat Saber IPA" not in text:
|
||||
messages.append("Logs/_latest.log does not show BSIPA startup")
|
||||
|
||||
if not state:
|
||||
messages.append(f"missing bootstrap state: {bootstrap_state_path(state_root, instance)}")
|
||||
|
||||
return {
|
||||
"ok": not messages,
|
||||
"messages": messages,
|
||||
"statePath": str(bootstrap_state_path(state_root, instance)),
|
||||
"logPath": str(log_path),
|
||||
"logSha256": sha256_file(log_path) if log_path.is_file() else None,
|
||||
"bootstrapRecordedAt": state.get("updatedAt"),
|
||||
}
|
||||
@@ -30,7 +30,7 @@ def check_lock(
|
||||
if not locked.asset:
|
||||
messages.append({"level": "error", "message": "missing asset"})
|
||||
else:
|
||||
asset_path = _find_asset(locked.asset, state_root, instance, repo_root)
|
||||
asset_path = _find_asset(locked.asset, state_root, instance, repo_root, locked.id)
|
||||
if not asset_path:
|
||||
messages.append({"level": "error", "message": "asset not found in downloads or repo assets"})
|
||||
elif locked.sha256 and sha256_file(asset_path) != locked.sha256:
|
||||
|
||||
+361
-16
@@ -4,23 +4,122 @@ import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from .config import instances_root, repo_root, state_root
|
||||
from .config import instances_roots, repo_root, state_root
|
||||
from .bootstrap import run_bootstrap
|
||||
from .bsipa import check_bsipa_health
|
||||
from .checker import check_lock
|
||||
from .github import fetch_releases
|
||||
from .installer import apply_plan, uninstall_plugin
|
||||
from .instances import get_instance, list_instances
|
||||
from .models import load_lockfile, load_registry
|
||||
from .models import Lockfile, Registry
|
||||
from .planner import create_plan
|
||||
from .scanner import scan_instance
|
||||
from .state import load_installed_state
|
||||
from .userdata import backup_userdata
|
||||
from .updates import check_updates
|
||||
from .userdata import sync_windows_data_repo
|
||||
|
||||
|
||||
def _json(data: Any) -> None:
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def installed_plugins_report(
|
||||
*,
|
||||
installed_state: dict[str, Any],
|
||||
registry: Registry,
|
||||
lockfile: Lockfile,
|
||||
) -> dict[str, Any]:
|
||||
locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins}
|
||||
plugins: list[dict[str, Any]] = []
|
||||
for plugin_id, plugin_state in sorted(installed_state.get("plugins", {}).items()):
|
||||
registry_plugin = registry.get(plugin_id)
|
||||
locked = locked_by_id.get(plugin_id)
|
||||
files = plugin_state.get("files", [])
|
||||
plugins.append(
|
||||
{
|
||||
"id": plugin_id,
|
||||
"name": registry_plugin.name if registry_plugin else plugin_id,
|
||||
"version": locked.tag if locked and locked.tag else "(not locked)",
|
||||
"asset": locked.asset if locked and locked.asset else "(unknown)",
|
||||
"repo": (locked.repo if locked and locked.repo else None)
|
||||
or (registry_plugin.repo if registry_plugin else None)
|
||||
or "(unknown)",
|
||||
"installedAt": plugin_state.get("installedAt", "(unknown)"),
|
||||
"fileCount": len(files),
|
||||
"files": files,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"instance": installed_state.get("instance", lockfile.instance),
|
||||
"beatSaberVersion": installed_state.get("beatSaberVersion", lockfile.beat_saber_version),
|
||||
"plugins": plugins,
|
||||
}
|
||||
|
||||
|
||||
def print_installed_plugins(report: dict[str, Any]) -> None:
|
||||
plugins = report["plugins"]
|
||||
print(f"{report['instance']} managed plugins ({len(plugins)})")
|
||||
if not plugins:
|
||||
print("No plugins have been installed by plugin-helper yet.")
|
||||
return
|
||||
|
||||
headers = ("Plugin", "Version", "Asset", "Files", "Installed")
|
||||
rows = [
|
||||
(
|
||||
f"{plugin['name']} ({plugin['id']})",
|
||||
plugin["version"],
|
||||
plugin["asset"],
|
||||
str(plugin["fileCount"]),
|
||||
plugin["installedAt"],
|
||||
)
|
||||
for plugin in plugins
|
||||
]
|
||||
widths = [
|
||||
max(len(headers[index]), *(len(row[index]) for row in rows))
|
||||
for index in range(len(headers))
|
||||
]
|
||||
header = " ".join(label.ljust(widths[index]) for index, label in enumerate(headers))
|
||||
print(header)
|
||||
print(" ".join("-" * width for width in widths))
|
||||
for row in rows:
|
||||
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
|
||||
|
||||
|
||||
def print_updates(report: dict[str, Any]) -> None:
|
||||
plugins = report["plugins"]
|
||||
summary = report["summary"]
|
||||
print(
|
||||
f"{report['instance']} updates: "
|
||||
f"{summary['updates']} available, {summary['current']} current, "
|
||||
f"{summary['warnings']} warnings, {summary['errors']} errors"
|
||||
)
|
||||
if not plugins:
|
||||
return
|
||||
|
||||
headers = ("Plugin", "Current", "Latest", "Asset", "Status")
|
||||
rows = [
|
||||
(
|
||||
f"{plugin['name']} ({plugin['id']})",
|
||||
plugin.get("currentTag") or "(none)",
|
||||
plugin.get("latestTag") or "(unknown)",
|
||||
plugin.get("latestAsset") or plugin.get("currentAsset") or "(unknown)",
|
||||
plugin["status"],
|
||||
)
|
||||
for plugin in plugins
|
||||
]
|
||||
widths = [
|
||||
max(len(headers[index]), *(len(row[index]) for row in rows))
|
||||
for index in range(len(headers))
|
||||
]
|
||||
print(" ".join(label.ljust(widths[index]) for index, label in enumerate(headers)))
|
||||
print(" ".join("-" * width for width in widths))
|
||||
for row in rows:
|
||||
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
|
||||
|
||||
|
||||
def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = False) -> None:
|
||||
default = argparse.SUPPRESS if suppress_default else None
|
||||
parser.add_argument("--instances-root", default=default, help="BSManager instances root")
|
||||
@@ -38,6 +137,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
|
||||
subcommands.add_parser(
|
||||
"menu",
|
||||
help="Open an interactive instance/action menu",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
|
||||
scan = subcommands.add_parser(
|
||||
"scan",
|
||||
help="Inspect installed Plugins, Libs, and IPA/Pending files",
|
||||
@@ -54,6 +159,16 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
state.add_argument("--instance", required=True)
|
||||
|
||||
installed = subcommands.add_parser(
|
||||
"installed",
|
||||
help="List plugins installed by plugin-helper with locked release versions",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
installed.add_argument("--instance", required=True)
|
||||
installed.add_argument("--registry", default="registry/plugins.toml")
|
||||
installed.add_argument("--lockfile")
|
||||
installed.add_argument("--json", action="store_true", help="Print full JSON output")
|
||||
|
||||
check = subcommands.add_parser(
|
||||
"check",
|
||||
help="Validate local registry, lockfile, and release asset readiness",
|
||||
@@ -64,6 +179,37 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
check.add_argument("--lockfile")
|
||||
check.add_argument("--json", action="store_true", help="Print full JSON check output")
|
||||
|
||||
bootstrap = subcommands.add_parser(
|
||||
"bootstrap",
|
||||
help="Install locked BSIPA, run IPA.exe -n through Proton, and record bootstrap files",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
bootstrap.add_argument("--instance", required=True)
|
||||
bootstrap.add_argument("--registry", default="registry/plugins.toml")
|
||||
bootstrap.add_argument("--lockfile")
|
||||
bootstrap.add_argument("--proton", help="Path to Proton executable")
|
||||
bootstrap.add_argument("--json", action="store_true", help="Print full JSON bootstrap output")
|
||||
|
||||
bootstrap_check = subcommands.add_parser(
|
||||
"bootstrap-check",
|
||||
help="Verify recorded BSIPA bootstrap state and latest IPA log",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
bootstrap_check.add_argument("--instance", required=True)
|
||||
bootstrap_check.add_argument("--json", action="store_true", help="Print full JSON bootstrap health output")
|
||||
|
||||
updates = subcommands.add_parser(
|
||||
"updates",
|
||||
help="Check GitHub for newer matching releases for locked plugins",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
updates.add_argument("--instance", required=True)
|
||||
updates.add_argument("--registry", default="registry/plugins.toml")
|
||||
updates.add_argument("--lockfile")
|
||||
updates.add_argument("--plugin", action="append", help="Check only this locked plugin id; repeatable")
|
||||
updates.add_argument("--include-prerelease", action="store_true", help="Include prerelease GitHub releases")
|
||||
updates.add_argument("--json", action="store_true", help="Print full JSON update output")
|
||||
|
||||
plan = subcommands.add_parser(
|
||||
"plan",
|
||||
help="Create a dry-run install plan from registry and lockfile",
|
||||
@@ -92,10 +238,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
backup = subcommands.add_parser(
|
||||
"backup-userdata",
|
||||
help="Create a timestamped UserData backup archive",
|
||||
help="Copy UserData and Windows AppData into the adjacent backups repo",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
backup.add_argument("--instance", required=True)
|
||||
backup.add_argument("--backup-root", default="../backups/beat-saber", help="Backup directory")
|
||||
backup.add_argument("--appdata-path", help="Override Beat Saber Windows AppData path")
|
||||
backup.add_argument("--no-appdata", action="store_true", help="Only copy UserData")
|
||||
|
||||
return parser
|
||||
|
||||
@@ -106,17 +255,115 @@ def _common_parent() -> argparse.ArgumentParser:
|
||||
return parent
|
||||
|
||||
|
||||
def _ask_choice(
|
||||
*,
|
||||
title: str,
|
||||
choices: list[tuple[str, str] | tuple[str, str, str]],
|
||||
input_func: Callable[[str], str] | None = None,
|
||||
) -> str | None:
|
||||
ask = input_func or input
|
||||
print()
|
||||
print(title)
|
||||
for index, choice in enumerate(choices, start=1):
|
||||
label = choice[1]
|
||||
print(f" {index}. {label}")
|
||||
if len(choice) > 2:
|
||||
print(f" {choice[2]}")
|
||||
print(" q. Quit")
|
||||
|
||||
while True:
|
||||
answer = ask("> ").strip().lower()
|
||||
if answer in {"q", "quit", "exit"}:
|
||||
return None
|
||||
if answer.isdigit():
|
||||
index = int(answer)
|
||||
if 1 <= index <= len(choices):
|
||||
return choices[index - 1][0]
|
||||
print("Choose a listed number, or q to quit.")
|
||||
|
||||
|
||||
def _run_menu(inst_roots: list[Path], st_root: Path, input_func: Callable[[str], str] | None = None) -> int:
|
||||
ask = input_func or input
|
||||
instances = list_instances(inst_roots)
|
||||
if not instances:
|
||||
print(f"No Beat Saber instances found under {', '.join(str(root) for root in inst_roots)}")
|
||||
return 1
|
||||
|
||||
instances_by_choice = {str(index): item for index, item in enumerate(instances, start=1)}
|
||||
instance_choices = [(str(index), f"{item.name} {item.path}") for index, item in enumerate(instances, start=1)]
|
||||
action_choices = [
|
||||
("installed", "Show managed installs", "Lists plugins recorded in plugin-helper state with locked versions and files."),
|
||||
("updates", "Check locked plugin updates", "Looks at GitHub releases for newer assets matching locked plugins."),
|
||||
("scan", "Scan installed files", "Counts files currently present in Plugins/, Libs/, and IPA/Pending/."),
|
||||
("check", "Check lockfile and assets", "Validates registry entries, lockfile data, local assets, and SHA-256 values."),
|
||||
("bootstrap", "Bootstrap BSIPA", "Fetches the locked BSIPA archive, installs it, runs IPA.exe -n, and records bootstrap files."),
|
||||
("bootstrap-check", "Check BSIPA bootstrap", "Verifies recorded bootstrap state and the latest BSIPA log evidence."),
|
||||
("plan", "Create install plan", "Writes a dry-run JSON plan for locked plugin files before anything is applied."),
|
||||
("apply", "Apply a plan by path", "Installs exactly the file changes from a previously generated plan JSON."),
|
||||
("backup-userdata", "Back up UserData", "Creates a timestamped archive of UserData before risky changes."),
|
||||
("change", "Choose another version", "Returns to the Beat Saber version picker."),
|
||||
]
|
||||
|
||||
selected_instance_key = _ask_choice(
|
||||
title="Choose Beat Saber version",
|
||||
choices=instance_choices,
|
||||
input_func=ask,
|
||||
)
|
||||
if selected_instance_key is None:
|
||||
return 0
|
||||
selected_instance = instances_by_choice[selected_instance_key]
|
||||
|
||||
while True:
|
||||
selected_action = _ask_choice(
|
||||
title=f"Choose action for {selected_instance.name}",
|
||||
choices=action_choices,
|
||||
input_func=ask,
|
||||
)
|
||||
if selected_action is None:
|
||||
return 0
|
||||
if selected_action == "change":
|
||||
selected_instance_key = _ask_choice(
|
||||
title="Choose Beat Saber version",
|
||||
choices=instance_choices,
|
||||
input_func=ask,
|
||||
)
|
||||
if selected_instance_key is None:
|
||||
return 0
|
||||
selected_instance = instances_by_choice[selected_instance_key]
|
||||
continue
|
||||
|
||||
command = [
|
||||
"--instances-root",
|
||||
str(selected_instance.path.parent),
|
||||
"--state-dir",
|
||||
str(st_root),
|
||||
selected_action,
|
||||
]
|
||||
if selected_action == "apply":
|
||||
plan_path = ask("Plan path> ").strip()
|
||||
if not plan_path:
|
||||
print("No plan path entered.")
|
||||
continue
|
||||
command.append(plan_path)
|
||||
else:
|
||||
command.extend(["--instance", selected_instance.name])
|
||||
|
||||
print()
|
||||
status = run(command)
|
||||
print(f"Command exited with status {status}")
|
||||
|
||||
|
||||
def run(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
inst_root = instances_root(getattr(args, "instances_root", None))
|
||||
inst_roots = instances_roots(getattr(args, "instances_root", None))
|
||||
st_root = state_root(getattr(args, "state_dir", None))
|
||||
|
||||
try:
|
||||
if args.command == "instances":
|
||||
found = list_instances(inst_root)
|
||||
found = list_instances(inst_roots)
|
||||
if not found:
|
||||
print(f"No Beat Saber instances found under {inst_root}")
|
||||
print(f"No Beat Saber instances found under {', '.join(str(root) for root in inst_roots)}")
|
||||
return 1
|
||||
for item in found:
|
||||
flags = []
|
||||
@@ -130,8 +377,11 @@ def run(argv: list[str] | None = None) -> int:
|
||||
print(f"{item.name}\t{item.path}{suffix}")
|
||||
return 0
|
||||
|
||||
if args.command == "menu":
|
||||
return _run_menu(inst_roots, st_root)
|
||||
|
||||
if args.command == "scan":
|
||||
instance = get_instance(inst_root, args.instance)
|
||||
instance = get_instance(inst_roots, args.instance)
|
||||
result = scan_instance(instance.path, include_hashes=args.hashes)
|
||||
if args.json:
|
||||
_json(result)
|
||||
@@ -147,6 +397,23 @@ def run(argv: list[str] | None = None) -> int:
|
||||
_json(load_installed_state(st_root, args.instance))
|
||||
return 0
|
||||
|
||||
if args.command == "installed":
|
||||
root = repo_root()
|
||||
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
|
||||
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
|
||||
if not lock_path.is_absolute():
|
||||
lock_path = (root / lock_path).resolve()
|
||||
result = installed_plugins_report(
|
||||
installed_state=load_installed_state(st_root, args.instance),
|
||||
registry=load_registry(registry_path),
|
||||
lockfile=load_lockfile(lock_path),
|
||||
)
|
||||
if args.json:
|
||||
_json(result)
|
||||
else:
|
||||
print_installed_plugins(result)
|
||||
return 0
|
||||
|
||||
if args.command == "check":
|
||||
root = repo_root()
|
||||
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
|
||||
@@ -176,8 +443,76 @@ def run(argv: list[str] | None = None) -> int:
|
||||
print(f" {message['level']}: {message['message']}")
|
||||
return 2 if result["summary"]["errors"] else 0
|
||||
|
||||
if args.command == "updates":
|
||||
root = repo_root()
|
||||
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
|
||||
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
|
||||
if not lock_path.is_absolute():
|
||||
lock_path = (root / lock_path).resolve()
|
||||
result = check_updates(
|
||||
registry=load_registry(registry_path),
|
||||
lockfile=load_lockfile(lock_path),
|
||||
fetch_releases=fetch_releases,
|
||||
selected=set(args.plugin) if args.plugin else None,
|
||||
include_prerelease=args.include_prerelease,
|
||||
)
|
||||
if args.json:
|
||||
_json(result)
|
||||
else:
|
||||
print_updates(result)
|
||||
return 2 if result["summary"]["errors"] else 0
|
||||
|
||||
if args.command == "bootstrap":
|
||||
instance = get_instance(inst_roots, args.instance)
|
||||
root = repo_root()
|
||||
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
|
||||
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
|
||||
if not lock_path.is_absolute():
|
||||
lock_path = (root / lock_path).resolve()
|
||||
lockfile = load_lockfile(lock_path)
|
||||
result = run_bootstrap(
|
||||
instance=args.instance,
|
||||
instance_path=instance.path,
|
||||
beat_saber_version=lockfile.beat_saber_version,
|
||||
registry=load_registry(registry_path),
|
||||
lockfile=lockfile,
|
||||
state_root=st_root,
|
||||
repo_root=root,
|
||||
proton=Path(args.proton).expanduser() if args.proton else None,
|
||||
progress=lambda message: print(f" {message}", flush=True),
|
||||
)
|
||||
if args.json:
|
||||
_json(result)
|
||||
else:
|
||||
delta = result["delta"]
|
||||
print(f"Bootstrap state: {result['statePath']}")
|
||||
print(f"Plan: {result['planPath']}")
|
||||
print(f"IPA.exe -n exit: {result['ipaExitCode']}")
|
||||
print(
|
||||
"Bootstrap files: "
|
||||
f"{len(delta['created'])} created, {len(delta['mutated'])} mutated, "
|
||||
f"{len(delta['removed'])} removed"
|
||||
)
|
||||
print(f"Health: {'ok' if result['health']['ok'] else 'error'}")
|
||||
for message in result["health"]["messages"]:
|
||||
print(f" {message}")
|
||||
return 0 if result["health"]["ok"] else 2
|
||||
|
||||
if args.command == "bootstrap-check":
|
||||
instance = get_instance(inst_roots, args.instance)
|
||||
result = check_bsipa_health(instance.path, st_root, args.instance)
|
||||
if args.json:
|
||||
_json(result)
|
||||
else:
|
||||
print(f"BSIPA bootstrap: {'ok' if result['ok'] else 'error'}")
|
||||
print(f"State: {result['statePath']}")
|
||||
print(f"Log: {result['logPath']}")
|
||||
for message in result["messages"]:
|
||||
print(f" {message}")
|
||||
return 0 if result["ok"] else 2
|
||||
|
||||
if args.command == "plan":
|
||||
instance = get_instance(inst_root, args.instance)
|
||||
instance = get_instance(inst_roots, args.instance)
|
||||
root = repo_root()
|
||||
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
|
||||
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
|
||||
@@ -211,7 +546,7 @@ def run(argv: list[str] | None = None) -> int:
|
||||
return 0
|
||||
|
||||
if args.command == "uninstall":
|
||||
instance = get_instance(inst_root, args.instance)
|
||||
instance = get_instance(inst_roots, args.instance)
|
||||
result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force)
|
||||
print(f"Removed: {len(result['removed'])}")
|
||||
if result["skipped"]:
|
||||
@@ -221,12 +556,22 @@ def run(argv: list[str] | None = None) -> int:
|
||||
return 0 if result["stateUpdated"] else 2
|
||||
|
||||
if args.command == "backup-userdata":
|
||||
instance = get_instance(inst_root, args.instance)
|
||||
result = backup_userdata(args.instance, instance.path, st_root)
|
||||
manifest = result["manifest"]
|
||||
print(f"Archive: {result['archive']}")
|
||||
print(f"Files: {manifest['fileCount']}")
|
||||
print(f"Bytes: {manifest['totalSize']}")
|
||||
instance = get_instance(inst_roots, args.instance)
|
||||
root = repo_root()
|
||||
backup_root = Path(args.backup_root).expanduser()
|
||||
if not backup_root.is_absolute():
|
||||
backup_root = (root / backup_root).resolve()
|
||||
result = sync_windows_data_repo(
|
||||
instance=args.instance,
|
||||
instance_path=instance.path,
|
||||
backup_root=backup_root,
|
||||
appdata_path=Path(args.appdata_path).expanduser() if args.appdata_path else None,
|
||||
include_appdata=not args.no_appdata,
|
||||
)
|
||||
print(f"Backup root: {result['backupRoot']}")
|
||||
for item in result["copied"]:
|
||||
print(f"{item['label']}: {item['fileCount']} files")
|
||||
print(f" {item['source']} -> {item['destination']}")
|
||||
return 0
|
||||
|
||||
except Exception as exc:
|
||||
|
||||
@@ -4,12 +4,21 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_INSTANCES_ROOT = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances")
|
||||
WINDOWS_INSTANCES_ROOT = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances")
|
||||
LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances"
|
||||
DEFAULT_INSTANCES_ROOTS = (WINDOWS_INSTANCES_ROOT, LOCAL_INSTANCES_ROOT)
|
||||
DEFAULT_INSTANCES_ROOT = WINDOWS_INSTANCES_ROOT
|
||||
|
||||
|
||||
def instances_root(value: str | None = None) -> Path:
|
||||
return instances_roots(value)[0]
|
||||
|
||||
|
||||
def instances_roots(value: str | None = None) -> list[Path]:
|
||||
raw = value or os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT")
|
||||
return Path(raw).expanduser() if raw else DEFAULT_INSTANCES_ROOT
|
||||
if raw:
|
||||
return [Path(item).expanduser() for item in raw.split(os.pathsep) if item]
|
||||
return list(DEFAULT_INSTANCES_ROOTS)
|
||||
|
||||
|
||||
def state_root(value: str | None = None) -> Path:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def fetch_releases(repo: str) -> list[dict[str, Any]]:
|
||||
token = os.environ.get("GITHUB_TOKEN")
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "plugin-helper",
|
||||
}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
request = Request(f"https://api.github.com/repos/{repo}/releases", headers=headers)
|
||||
with urlopen(request, timeout=20) as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
if not isinstance(data, list):
|
||||
raise ValueError(f"unexpected GitHub releases response for {repo}")
|
||||
return data
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -13,6 +14,9 @@ class Instance:
|
||||
has_userdata: bool
|
||||
|
||||
|
||||
RootInput = Path | Sequence[Path]
|
||||
|
||||
|
||||
def looks_like_instance(path: Path) -> bool:
|
||||
return (
|
||||
(path / "Beat Saber_Data").is_dir()
|
||||
@@ -22,7 +26,13 @@ def looks_like_instance(path: Path) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def list_instances(root: Path) -> list[Instance]:
|
||||
def _root_list(root: RootInput) -> list[Path]:
|
||||
if isinstance(root, Path):
|
||||
return [root]
|
||||
return list(root)
|
||||
|
||||
|
||||
def _list_instances_one(root: Path) -> list[Instance]:
|
||||
if not root.exists():
|
||||
return []
|
||||
instances: list[Instance] = []
|
||||
@@ -41,7 +51,20 @@ def list_instances(root: Path) -> list[Instance]:
|
||||
return instances
|
||||
|
||||
|
||||
def get_instance(root: Path, name: str) -> Instance:
|
||||
def list_instances(root: RootInput) -> list[Instance]:
|
||||
instances: list[Instance] = []
|
||||
seen_paths: set[Path] = set()
|
||||
for item in _root_list(root):
|
||||
for instance in _list_instances_one(item):
|
||||
resolved = instance.path.resolve(strict=False)
|
||||
if resolved in seen_paths:
|
||||
continue
|
||||
seen_paths.add(resolved)
|
||||
instances.append(instance)
|
||||
return sorted(instances, key=lambda item: (item.name, str(item.path)))
|
||||
|
||||
|
||||
def _get_instance_one(root: Path, name: str) -> Instance:
|
||||
path = root / name
|
||||
if not path.is_dir() or not looks_like_instance(path):
|
||||
raise FileNotFoundError(f"Beat Saber instance not found: {path}")
|
||||
@@ -52,3 +75,22 @@ def get_instance(root: Path, name: str) -> Instance:
|
||||
has_libs=(path / "Libs").is_dir(),
|
||||
has_userdata=(path / "UserData").is_dir(),
|
||||
)
|
||||
|
||||
|
||||
def get_instance(root: RootInput, name: str) -> Instance:
|
||||
if isinstance(root, Path):
|
||||
return _get_instance_one(root, name)
|
||||
|
||||
matches: list[Instance] = []
|
||||
for item in _root_list(root):
|
||||
try:
|
||||
matches.append(_get_instance_one(item, name))
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if not matches:
|
||||
searched = ", ".join(str(item) for item in _root_list(root))
|
||||
raise FileNotFoundError(f"Beat Saber instance not found: {name} under {searched}")
|
||||
if len(matches) > 1:
|
||||
paths = ", ".join(str(item.path) for item in matches)
|
||||
raise ValueError(f"Beat Saber instance name is ambiguous: {name}; matches: {paths}")
|
||||
return matches[0]
|
||||
|
||||
@@ -9,7 +9,8 @@ from zipfile import ZipFile
|
||||
|
||||
from .fsutil import ensure_relative, sha256_bytes, sha256_file
|
||||
from .models import Lockfile, Registry, VALID_STRATEGIES
|
||||
from .state import downloads_dir, plans_dir
|
||||
from .bsipa import BSIPA_PLUGIN_ID, check_bsipa_health
|
||||
from .state import downloads_dir, plans_dir, plugin_downloads_dir
|
||||
|
||||
|
||||
ALLOWED_BSIPA_TOP_LEVEL = {"IPA", "Libs", "Plugins"}
|
||||
@@ -19,13 +20,15 @@ def _now_slug() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def _find_asset(asset: str, state_root: Path, instance: str, repo_root: Path) -> Path | None:
|
||||
def _find_asset(asset: str, state_root: Path, instance: str, repo_root: Path, plugin_id: str | None = None) -> Path | None:
|
||||
candidates = [
|
||||
Path(asset).expanduser(),
|
||||
downloads_dir(state_root, instance) / asset,
|
||||
repo_root / "assets" / asset,
|
||||
repo_root / "locks" / "assets" / asset,
|
||||
]
|
||||
if plugin_id:
|
||||
candidates.insert(1, plugin_downloads_dir(state_root, instance, plugin_id) / asset)
|
||||
candidates.insert(2 if plugin_id else 1, downloads_dir(state_root, instance) / asset)
|
||||
for candidate in candidates:
|
||||
if candidate.exists() and candidate.is_file():
|
||||
return candidate
|
||||
@@ -72,11 +75,20 @@ def create_plan(
|
||||
state_root: Path,
|
||||
repo_root: Path,
|
||||
selected: set[str] | None = None,
|
||||
require_bootstrap: bool = True,
|
||||
) -> tuple[dict[str, Any], Path]:
|
||||
selected_ids = selected or {plugin.id for plugin in lockfile.plugins}
|
||||
changes: list[dict[str, Any]] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
has_locked_bsipa = any(plugin.id == BSIPA_PLUGIN_ID for plugin in lockfile.plugins)
|
||||
planning_ordinary_plugins = any(plugin_id != BSIPA_PLUGIN_ID for plugin_id in selected_ids)
|
||||
if require_bootstrap and has_locked_bsipa and planning_ordinary_plugins:
|
||||
health = check_bsipa_health(instance_path, state_root, instance)
|
||||
if not health["ok"]:
|
||||
joined = "; ".join(health["messages"])
|
||||
raise ValueError(f"BSIPA bootstrap is not healthy; run bootstrap first: {joined}")
|
||||
|
||||
for locked in lockfile.plugins:
|
||||
if locked.id not in selected_ids:
|
||||
continue
|
||||
@@ -91,10 +103,11 @@ def create_plan(
|
||||
if registry_plugin and not _asset_matches_patterns(Path(locked.asset).name, registry_plugin.asset_patterns):
|
||||
warnings.append(f"{locked.id}: asset does not match registry patterns")
|
||||
|
||||
asset_path = _find_asset(locked.asset, state_root, instance, repo_root)
|
||||
asset_path = _find_asset(locked.asset, state_root, instance, repo_root, locked.id)
|
||||
if not asset_path:
|
||||
raise FileNotFoundError(
|
||||
f"{locked.id}: asset not found: {locked.asset}; put it in {downloads_dir(state_root, instance)}"
|
||||
f"{locked.id}: asset not found: {locked.asset}; put it in "
|
||||
f"{plugin_downloads_dir(state_root, instance, locked.id)}"
|
||||
)
|
||||
asset_sha = sha256_file(asset_path)
|
||||
if locked.sha256 and locked.sha256 != asset_sha:
|
||||
|
||||
@@ -7,6 +7,8 @@ from .fsutil import sha256_file
|
||||
|
||||
|
||||
SCAN_DIRS = ("Plugins", "Libs", "IPA/Pending")
|
||||
BOOTSTRAP_DIRS = ("Libs", "IPA")
|
||||
BOOTSTRAP_ROOT_GLOBS = ("winhttp.dll", "IPA.exe*")
|
||||
|
||||
|
||||
def scan_instance(instance_path: Path, include_hashes: bool = False) -> dict[str, Any]:
|
||||
@@ -31,3 +33,32 @@ def scan_instance(instance_path: Path, include_hashes: bool = False) -> dict[str
|
||||
"pending": sum(1 for item in files if item["path"].startswith("IPA/Pending/")),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def scan_bootstrap_files(instance_path: Path) -> list[dict[str, Any]]:
|
||||
files_by_path: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for pattern in BOOTSTRAP_ROOT_GLOBS:
|
||||
for path in sorted(instance_path.glob(pattern)):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(instance_path).as_posix()
|
||||
files_by_path[rel] = {
|
||||
"path": rel,
|
||||
"size": path.stat().st_size,
|
||||
"sha256": sha256_file(path),
|
||||
}
|
||||
|
||||
for dirname in BOOTSTRAP_DIRS:
|
||||
root = instance_path / dirname
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in sorted(item for item in root.rglob("*") if item.is_file()):
|
||||
rel = path.relative_to(instance_path).as_posix()
|
||||
files_by_path[rel] = {
|
||||
"path": rel,
|
||||
"size": path.stat().st_size,
|
||||
"sha256": sha256_file(path),
|
||||
}
|
||||
|
||||
return [files_by_path[path] for path in sorted(files_by_path)]
|
||||
|
||||
@@ -15,6 +15,10 @@ def installed_state_path(state_root: Path, instance: str) -> Path:
|
||||
return instance_state_dir(state_root, instance) / "installed.json"
|
||||
|
||||
|
||||
def bootstrap_state_path(state_root: Path, instance: str) -> Path:
|
||||
return instance_state_dir(state_root, instance) / "bootstrap.json"
|
||||
|
||||
|
||||
def load_installed_state(state_root: Path, instance: str) -> dict[str, Any]:
|
||||
return read_json(
|
||||
installed_state_path(state_root, instance),
|
||||
@@ -22,6 +26,16 @@ def load_installed_state(state_root: Path, instance: str) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
def load_bootstrap_state(state_root: Path, instance: str) -> dict[str, Any]:
|
||||
return read_json(bootstrap_state_path(state_root, instance), {})
|
||||
|
||||
|
||||
def save_bootstrap_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
|
||||
state.setdefault("instance", instance)
|
||||
state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
atomic_write_json(bootstrap_state_path(state_root, instance), state)
|
||||
|
||||
|
||||
def save_installed_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
|
||||
state.setdefault("instance", instance)
|
||||
state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
@@ -40,6 +54,12 @@ def downloads_dir(state_root: Path, instance: str) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def plugin_downloads_dir(state_root: Path, instance: str, plugin_id: str) -> Path:
|
||||
path = downloads_dir(state_root, instance) / plugin_id
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def backups_dir(state_root: Path, instance: str) -> Path:
|
||||
path = instance_state_dir(state_root, instance) / "backups"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from .models import Lockfile, Registry
|
||||
|
||||
|
||||
FetchReleases = Callable[[str], list[dict[str, Any]]]
|
||||
|
||||
|
||||
def _release_tag(release: dict[str, Any]) -> str:
|
||||
return str(release.get("tag_name") or "")
|
||||
|
||||
|
||||
def _release_assets(release: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
assets = release.get("assets") or []
|
||||
return [asset for asset in assets if isinstance(asset, dict)]
|
||||
|
||||
|
||||
def _asset_name(asset: dict[str, Any]) -> str:
|
||||
return str(asset.get("name") or "")
|
||||
|
||||
|
||||
def _semver_key(tag: str) -> tuple[int, tuple[int, ...], str]:
|
||||
match = re.search(r"(\d+(?:\.\d+){0,3})", tag)
|
||||
if not match:
|
||||
return (0, (), tag)
|
||||
return (1, tuple(int(part) for part in match.group(1).split(".")), tag)
|
||||
|
||||
|
||||
def _sorted_releases(releases: list[dict[str, Any]], include_prerelease: bool) -> list[dict[str, Any]]:
|
||||
candidates = [
|
||||
release
|
||||
for release in releases
|
||||
if not release.get("draft") and (include_prerelease or not release.get("prerelease"))
|
||||
]
|
||||
return sorted(
|
||||
candidates,
|
||||
key=lambda release: (
|
||||
str(release.get("published_at") or release.get("created_at") or ""),
|
||||
_semver_key(_release_tag(release)),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def _matching_assets(
|
||||
release: dict[str, Any],
|
||||
*,
|
||||
asset_patterns: tuple[str, ...],
|
||||
current_asset: str | None,
|
||||
beat_saber_version: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
assets = _release_assets(release)
|
||||
if asset_patterns:
|
||||
assets = [
|
||||
asset
|
||||
for asset in assets
|
||||
if any(fnmatch.fnmatch(_asset_name(asset), pattern) for pattern in asset_patterns)
|
||||
]
|
||||
if not assets:
|
||||
return []
|
||||
|
||||
exact_version = [asset for asset in assets if _asset_name(asset) == f"{beat_saber_version}.zip"]
|
||||
if exact_version:
|
||||
return exact_version
|
||||
if current_asset:
|
||||
same_name = [asset for asset in assets if _asset_name(asset) == current_asset]
|
||||
if same_name:
|
||||
return same_name
|
||||
contains_version = [asset for asset in assets if beat_saber_version in _asset_name(asset)]
|
||||
return contains_version or assets
|
||||
|
||||
|
||||
def _find_latest_matching_release(
|
||||
releases: list[dict[str, Any]],
|
||||
*,
|
||||
asset_patterns: tuple[str, ...],
|
||||
current_asset: str | None,
|
||||
beat_saber_version: str,
|
||||
include_prerelease: bool,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
||||
for release in _sorted_releases(releases, include_prerelease):
|
||||
assets = _matching_assets(
|
||||
release,
|
||||
asset_patterns=asset_patterns,
|
||||
current_asset=current_asset,
|
||||
beat_saber_version=beat_saber_version,
|
||||
)
|
||||
if assets:
|
||||
return release, assets[0]
|
||||
return None
|
||||
|
||||
|
||||
def check_updates(
|
||||
*,
|
||||
registry: Registry,
|
||||
lockfile: Lockfile,
|
||||
fetch_releases: FetchReleases,
|
||||
selected: set[str] | None = None,
|
||||
include_prerelease: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
selected_ids = selected or {plugin.id for plugin in lockfile.plugins}
|
||||
plugins: list[dict[str, Any]] = []
|
||||
summary = {"current": 0, "updates": 0, "warnings": 0, "errors": 0}
|
||||
|
||||
for locked in lockfile.plugins:
|
||||
if locked.id not in selected_ids:
|
||||
continue
|
||||
registry_plugin = registry.get(locked.id)
|
||||
repo = locked.repo or (registry_plugin.repo if registry_plugin else None)
|
||||
entry: dict[str, Any] = {
|
||||
"id": locked.id,
|
||||
"name": registry_plugin.name if registry_plugin else locked.id,
|
||||
"repo": repo,
|
||||
"currentTag": locked.tag,
|
||||
"currentAsset": locked.asset,
|
||||
"currentSha256": locked.sha256,
|
||||
"latestTag": None,
|
||||
"latestAsset": None,
|
||||
"latestAssetSha256": None,
|
||||
"status": "unknown",
|
||||
"messages": [],
|
||||
}
|
||||
if not repo:
|
||||
entry["status"] = "warning"
|
||||
entry["messages"].append("missing repository")
|
||||
summary["warnings"] += 1
|
||||
plugins.append(entry)
|
||||
continue
|
||||
|
||||
try:
|
||||
releases = fetch_releases(repo)
|
||||
except Exception as exc:
|
||||
entry["status"] = "error"
|
||||
entry["messages"].append(str(exc))
|
||||
summary["errors"] += 1
|
||||
plugins.append(entry)
|
||||
continue
|
||||
|
||||
match = _find_latest_matching_release(
|
||||
releases,
|
||||
asset_patterns=registry_plugin.asset_patterns if registry_plugin else (),
|
||||
current_asset=locked.asset,
|
||||
beat_saber_version=lockfile.beat_saber_version,
|
||||
include_prerelease=include_prerelease,
|
||||
)
|
||||
if not match:
|
||||
entry["status"] = "warning"
|
||||
entry["messages"].append("no matching release asset found")
|
||||
summary["warnings"] += 1
|
||||
plugins.append(entry)
|
||||
continue
|
||||
|
||||
latest_release, latest_asset = match
|
||||
entry["latestTag"] = _release_tag(latest_release)
|
||||
entry["latestAsset"] = _asset_name(latest_asset)
|
||||
entry["latestAssetSha256"] = str(latest_asset.get("digest") or "").removeprefix("sha256:") or None
|
||||
entry["latestUrl"] = latest_release.get("html_url")
|
||||
entry["latestAssetUrl"] = latest_asset.get("browser_download_url")
|
||||
same_release = locked.tag == entry["latestTag"] and locked.asset == entry["latestAsset"]
|
||||
same_hash = not entry["latestAssetSha256"] or locked.sha256 == entry["latestAssetSha256"]
|
||||
if same_release and same_hash:
|
||||
entry["status"] = "current"
|
||||
summary["current"] += 1
|
||||
else:
|
||||
entry["status"] = "update"
|
||||
summary["updates"] += 1
|
||||
plugins.append(entry)
|
||||
|
||||
return {
|
||||
"instance": lockfile.instance,
|
||||
"beatSaberVersion": lockfile.beat_saber_version,
|
||||
"includePrerelease": include_prerelease,
|
||||
"summary": summary,
|
||||
"plugins": plugins,
|
||||
}
|
||||
@@ -1,16 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import fnmatch
|
||||
import shutil
|
||||
import tarfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from .fsutil import sha256_file
|
||||
from .state import backups_dir
|
||||
|
||||
|
||||
DEFAULT_BACKUP_EXCLUDES = (
|
||||
"BeatLeader/Replays",
|
||||
"BeatLeader/Replays/**",
|
||||
"BeatLeader/ReplayerCache",
|
||||
"BeatLeader/ReplayerCache/**",
|
||||
"BeatLeader/LeaderboardsCache",
|
||||
"BeatLeader/LeaderboardsCache/**",
|
||||
"BeatLeader/ReplayHeadersCache",
|
||||
"ScoreSaber/Replays",
|
||||
"ScoreSaber/Replays/**",
|
||||
"BeatSaberPlus/Cache",
|
||||
"BeatSaberPlus/Cache/**",
|
||||
"BeatSaverNotifier.json",
|
||||
"Accsaber/PlayerScoreCache.json",
|
||||
"NalulunaAvatars/cache",
|
||||
"NalulunaAvatars/cache/**",
|
||||
"SongDetailsCache.proto",
|
||||
"com.unity.addressables",
|
||||
"com.unity.addressables/**",
|
||||
"*.log",
|
||||
"*.log.*",
|
||||
)
|
||||
|
||||
|
||||
def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dict[str, Any]:
|
||||
source = instance_path / "UserData"
|
||||
if not source.is_dir():
|
||||
@@ -44,3 +70,94 @@ def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dic
|
||||
handle.flush()
|
||||
archive.add(handle.name, arcname="manifest.json")
|
||||
return {"archive": str(destination), "manifest": manifest}
|
||||
|
||||
|
||||
def infer_windows_appdata_path(instance_path: Path) -> Path:
|
||||
parts = instance_path.resolve().parts
|
||||
try:
|
||||
users_index = parts.index("Users")
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"cannot infer Windows user profile from instance path: {instance_path}") from exc
|
||||
if users_index + 1 >= len(parts):
|
||||
raise ValueError(f"cannot infer Windows user profile from instance path: {instance_path}")
|
||||
profile = Path(*parts[: users_index + 2])
|
||||
return profile / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber"
|
||||
|
||||
|
||||
def sync_windows_data_repo(
|
||||
*,
|
||||
instance: str,
|
||||
instance_path: Path,
|
||||
backup_root: Path,
|
||||
appdata_path: Path | None = None,
|
||||
include_appdata: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
sources: list[tuple[str, Path, Path]] = [
|
||||
("UserData", instance_path / "UserData", backup_root / "UserData"),
|
||||
]
|
||||
if include_appdata:
|
||||
appdata = appdata_path or infer_windows_appdata_path(instance_path)
|
||||
sources.append(("AppData", appdata, backup_root / "AppData"))
|
||||
|
||||
for label, source, _ in sources:
|
||||
if not source.is_dir():
|
||||
raise FileNotFoundError(f"{label} directory not found: {source}")
|
||||
|
||||
backup_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
copied: list[dict[str, Any]] = []
|
||||
skipped: list[str] = []
|
||||
for label, source, destination in sources:
|
||||
if destination.exists():
|
||||
shutil.rmtree(destination)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(
|
||||
source,
|
||||
destination,
|
||||
symlinks=True,
|
||||
ignore=_ignore_backup_paths(source, skipped),
|
||||
)
|
||||
copied.append(
|
||||
{
|
||||
"label": label,
|
||||
"source": str(source),
|
||||
"destination": str(destination),
|
||||
"fileCount": sum(1 for item in destination.rglob("*") if item.is_file()),
|
||||
}
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"instance": instance,
|
||||
"sources": copied,
|
||||
"excludePatterns": list(DEFAULT_BACKUP_EXCLUDES),
|
||||
"skipped": sorted(set(skipped)),
|
||||
}
|
||||
(backup_root / "backup-descriptor.json").write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
return {
|
||||
"backupRoot": str(backup_root),
|
||||
"copied": copied,
|
||||
"manifest": manifest,
|
||||
}
|
||||
|
||||
|
||||
def _ignore_backup_paths(source_root: Path, skipped: list[str]) -> Callable[[str, list[str]], set[str]]:
|
||||
def ignore(current_dir: str, names: list[str]) -> set[str]:
|
||||
ignored: set[str] = set()
|
||||
current_path = Path(current_dir)
|
||||
for name in names:
|
||||
relative = (current_path / name).relative_to(source_root).as_posix()
|
||||
if _is_excluded(relative):
|
||||
ignored.add(name)
|
||||
skipped.append(relative)
|
||||
return ignored
|
||||
|
||||
return ignore
|
||||
|
||||
|
||||
def _is_excluded(relative_path: str) -> bool:
|
||||
return any(fnmatch.fnmatchcase(relative_path, pattern) for pattern in DEFAULT_BACKUP_EXCLUDES)
|
||||
|
||||
+462
-6
@@ -1,19 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import os
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from zipfile import ZipFile
|
||||
|
||||
from plugin_helper.bootstrap import _run_ipa
|
||||
from plugin_helper.checker import check_lock
|
||||
from plugin_helper.cli import installed_plugins_report, run
|
||||
from plugin_helper.fsutil import sha256_file
|
||||
from plugin_helper.installer import apply_plan, uninstall_plugin
|
||||
from plugin_helper.instances import get_instance, list_instances
|
||||
from plugin_helper.models import Lockfile, LockedPlugin, Registry, RegistryPlugin
|
||||
from plugin_helper.planner import create_plan
|
||||
from plugin_helper.scanner import scan_instance
|
||||
from plugin_helper.state import downloads_dir, load_installed_state
|
||||
from plugin_helper.userdata import backup_userdata
|
||||
from plugin_helper.scanner import scan_bootstrap_files, scan_instance
|
||||
from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir, save_bootstrap_state
|
||||
from plugin_helper.updates import check_updates
|
||||
from plugin_helper.userdata import backup_userdata, infer_windows_appdata_path, sync_windows_data_repo
|
||||
|
||||
|
||||
class PluginHelperTests(unittest.TestCase):
|
||||
@@ -37,6 +44,87 @@ class PluginHelperTests(unittest.TestCase):
|
||||
self.assertEqual(scan["files"][0]["path"], "Plugins/Example.dll")
|
||||
self.assertIn("sha256", scan["files"][0])
|
||||
|
||||
def test_multi_root_instances_and_ambiguous_lookup(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
windows = root / "windows"
|
||||
local = root / "local"
|
||||
win_inst = windows / "1.44.1"
|
||||
local_inst = local / "1.44.1"
|
||||
(win_inst / "Beat Saber_Data").mkdir(parents=True)
|
||||
(local_inst / "Beat Saber_Data").mkdir(parents=True)
|
||||
|
||||
instances = list_instances([windows, local])
|
||||
|
||||
self.assertEqual(len(instances), 2)
|
||||
self.assertEqual({item.path for item in instances}, {win_inst, local_inst})
|
||||
with self.assertRaisesRegex(ValueError, "ambiguous"):
|
||||
get_instance([windows, local], "1.44.1")
|
||||
|
||||
def test_menu_selects_instance_and_action(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
instance = root / "instances" / "1.40.8"
|
||||
state = root / "state"
|
||||
(instance / "Beat Saber_Data").mkdir(parents=True)
|
||||
(instance / "Plugins").mkdir()
|
||||
|
||||
answers = iter(["1", "3", "q"])
|
||||
output = StringIO()
|
||||
with patch("builtins.input", side_effect=lambda _: next(answers)), patch("sys.stdout", output):
|
||||
status = run(
|
||||
[
|
||||
"--instances-root",
|
||||
str(root / "instances"),
|
||||
"--state-dir",
|
||||
str(state),
|
||||
"menu",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(status, 0)
|
||||
self.assertIn("Counts files currently present", output.getvalue())
|
||||
|
||||
def test_menu_routes_duplicate_instance_names_by_selected_root(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
first_root = root / "a-root"
|
||||
second_root = root / "z-root"
|
||||
first = first_root / "1.44.1"
|
||||
second = second_root / "1.44.1"
|
||||
state = root / "state"
|
||||
(first / "Beat Saber_Data").mkdir(parents=True)
|
||||
(second / "Beat Saber_Data").mkdir(parents=True)
|
||||
(second / "Plugins").mkdir()
|
||||
(second / "Plugins" / "Example.dll").write_bytes(b"dll")
|
||||
|
||||
answers = iter(["2", "3", "q"])
|
||||
output = StringIO()
|
||||
with patch("builtins.input", side_effect=lambda _: next(answers)), patch("sys.stdout", output):
|
||||
status = run(
|
||||
[
|
||||
"--instances-root",
|
||||
os.pathsep.join([str(first_root), str(second_root)]),
|
||||
"--state-dir",
|
||||
str(state),
|
||||
"menu",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(status, 0)
|
||||
self.assertIn("1.44.1: 1 files", output.getvalue())
|
||||
|
||||
def test_run_ipa_timeout_returns_control(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = _run_ipa(
|
||||
command=["python", "-c", "import time; time.sleep(30)"],
|
||||
instance_path=Path(tmp),
|
||||
timeout_seconds=1,
|
||||
)
|
||||
|
||||
self.assertTrue(result["timedOut"])
|
||||
self.assertNotEqual(result["returncode"], 0)
|
||||
|
||||
def test_plan_apply_and_uninstall_dll(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
@@ -46,7 +134,7 @@ class PluginHelperTests(unittest.TestCase):
|
||||
(instance / "Beat Saber_Data").mkdir()
|
||||
(instance / "Plugins").mkdir()
|
||||
|
||||
asset = downloads_dir(state, "1.40.8") / "Example.dll"
|
||||
asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll"
|
||||
asset.write_bytes(b"managed dll")
|
||||
|
||||
registry = Registry(
|
||||
@@ -103,7 +191,7 @@ class PluginHelperTests(unittest.TestCase):
|
||||
state = work / "state"
|
||||
instance.mkdir(parents=True)
|
||||
(instance / "Beat Saber_Data").mkdir()
|
||||
asset = downloads_dir(state, "1.40.8") / "Example.zip"
|
||||
asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.zip"
|
||||
with ZipFile(asset, "w") as archive:
|
||||
archive.writestr("Plugins/Example.dll", b"dll")
|
||||
|
||||
@@ -144,6 +232,49 @@ class PluginHelperTests(unittest.TestCase):
|
||||
apply_plan(plan, state)
|
||||
self.assertEqual((instance / "IPA" / "Pending" / "Plugins" / "Example.dll").read_bytes(), b"dll")
|
||||
|
||||
def test_plan_still_finds_legacy_flat_downloads(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
instance = work / "instances" / "1.40.8"
|
||||
state = work / "state"
|
||||
instance.mkdir(parents=True)
|
||||
(instance / "Beat Saber_Data").mkdir()
|
||||
asset = downloads_dir(state, "1.40.8") / "Example.dll"
|
||||
asset.write_bytes(b"legacy flat download")
|
||||
|
||||
plan, _ = create_plan(
|
||||
instance="1.40.8",
|
||||
instance_path=instance,
|
||||
beat_saber_version="1.40.8",
|
||||
registry=Registry(
|
||||
{
|
||||
"example": RegistryPlugin(
|
||||
id="example",
|
||||
name="Example",
|
||||
repo=None,
|
||||
install_strategy="dll-to-plugins",
|
||||
)
|
||||
}
|
||||
),
|
||||
lockfile=Lockfile(
|
||||
beat_saber_version="1.40.8",
|
||||
instance="1.40.8",
|
||||
plugins=(
|
||||
LockedPlugin(
|
||||
id="example",
|
||||
repo=None,
|
||||
tag=None,
|
||||
asset="Example.dll",
|
||||
sha256=sha256_file(asset),
|
||||
),
|
||||
),
|
||||
),
|
||||
state_root=state,
|
||||
repo_root=work,
|
||||
)
|
||||
|
||||
self.assertEqual(plan["changes"][0]["source"], str(asset))
|
||||
|
||||
def test_zip_member_cannot_escape_instance(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
@@ -151,7 +282,7 @@ class PluginHelperTests(unittest.TestCase):
|
||||
state = work / "state"
|
||||
instance.mkdir(parents=True)
|
||||
(instance / "Beat Saber_Data").mkdir()
|
||||
asset = downloads_dir(state, "1.40.8") / "Bad.zip"
|
||||
asset = plugin_downloads_dir(state, "1.40.8", "bad") / "Bad.zip"
|
||||
with ZipFile(asset, "w") as archive:
|
||||
archive.writestr("../Bad.dll", b"dll")
|
||||
|
||||
@@ -190,6 +321,103 @@ class PluginHelperTests(unittest.TestCase):
|
||||
repo_root=work,
|
||||
)
|
||||
|
||||
def test_scan_bootstrap_files_includes_root_ipa_and_bsipa_dirs(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
instance = Path(tmp)
|
||||
(instance / "IPA" / "Backups").mkdir(parents=True)
|
||||
(instance / "Libs").mkdir()
|
||||
(instance / "winhttp.dll").write_bytes(b"proxy")
|
||||
(instance / "IPA.exe").write_bytes(b"ipa")
|
||||
(instance / "IPA.exe.config").write_bytes(b"config")
|
||||
(instance / "IPA" / "Backups" / "Beat Saber.exe.bak").write_bytes(b"backup")
|
||||
(instance / "Libs" / "0Harmony.dll").write_bytes(b"harmony")
|
||||
|
||||
paths = [item["path"] for item in scan_bootstrap_files(instance)]
|
||||
|
||||
self.assertEqual(
|
||||
paths,
|
||||
[
|
||||
"IPA.exe",
|
||||
"IPA.exe.config",
|
||||
"IPA/Backups/Beat Saber.exe.bak",
|
||||
"Libs/0Harmony.dll",
|
||||
"winhttp.dll",
|
||||
],
|
||||
)
|
||||
|
||||
def test_plan_requires_healthy_bootstrap_for_locked_bsipa_dependencies(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
instance = work / "instances" / "1.44.1"
|
||||
state = work / "state"
|
||||
instance.mkdir(parents=True)
|
||||
(instance / "Beat Saber_Data").mkdir()
|
||||
asset = plugin_downloads_dir(state, "1.44.1", "example") / "Example.dll"
|
||||
asset.write_bytes(b"managed dll")
|
||||
|
||||
registry = Registry(
|
||||
{
|
||||
"bsipa": RegistryPlugin(
|
||||
id="bsipa",
|
||||
name="BSIPA",
|
||||
repo=None,
|
||||
install_strategy="root-zip",
|
||||
),
|
||||
"example": RegistryPlugin(
|
||||
id="example",
|
||||
name="Example",
|
||||
repo=None,
|
||||
install_strategy="dll-to-plugins",
|
||||
),
|
||||
}
|
||||
)
|
||||
lockfile = Lockfile(
|
||||
beat_saber_version="1.44.1",
|
||||
instance="1.44.1",
|
||||
plugins=(
|
||||
LockedPlugin(id="bsipa", repo=None, tag="4.3.7", asset="BSIPA.zip", sha256=None),
|
||||
LockedPlugin(
|
||||
id="example",
|
||||
repo=None,
|
||||
tag="v1.0.0",
|
||||
asset="Example.dll",
|
||||
sha256=sha256_file(asset),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "BSIPA bootstrap is not healthy"):
|
||||
create_plan(
|
||||
instance="1.44.1",
|
||||
instance_path=instance,
|
||||
beat_saber_version="1.44.1",
|
||||
registry=registry,
|
||||
lockfile=lockfile,
|
||||
state_root=state,
|
||||
repo_root=work,
|
||||
selected={"example"},
|
||||
)
|
||||
|
||||
(instance / "IPA").mkdir()
|
||||
(instance / "Libs").mkdir()
|
||||
(instance / "Logs").mkdir()
|
||||
(instance / "IPA.exe").write_bytes(b"ipa")
|
||||
(instance / "winhttp.dll").write_bytes(b"proxy")
|
||||
(instance / "Logs" / "_latest.log").write_text("Beat Saber IPA (BSIPA): 4.3.7\n", encoding="utf-8")
|
||||
save_bootstrap_state(state, "1.44.1", {"files": scan_bootstrap_files(instance)})
|
||||
|
||||
plan, _ = create_plan(
|
||||
instance="1.44.1",
|
||||
instance_path=instance,
|
||||
beat_saber_version="1.44.1",
|
||||
registry=registry,
|
||||
lockfile=lockfile,
|
||||
state_root=state,
|
||||
repo_root=work,
|
||||
selected={"example"},
|
||||
)
|
||||
self.assertEqual(plan["changes"][0]["target"], "Plugins/Example.dll")
|
||||
|
||||
def test_userdata_backup_contains_manifest(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -202,6 +430,56 @@ class PluginHelperTests(unittest.TestCase):
|
||||
self.assertTrue(Path(result["archive"]).exists())
|
||||
self.assertEqual(result["manifest"]["fileCount"], 1)
|
||||
|
||||
def test_infer_windows_appdata_path_from_mounted_instance(self) -> None:
|
||||
instance = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances/1.44.1")
|
||||
|
||||
self.assertEqual(
|
||||
infer_windows_appdata_path(instance),
|
||||
Path("/home/pleb/Windows/Users/pleb/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber"),
|
||||
)
|
||||
|
||||
def test_sync_windows_data_repo_copies_into_stable_backup_root(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
instance = root / "Users" / "pleb" / "BSManager" / "BSInstances" / "1.44.1"
|
||||
appdata = root / "Users" / "pleb" / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber"
|
||||
backup_repo = root / "backup"
|
||||
(instance / "UserData").mkdir(parents=True)
|
||||
(instance / "UserData" / "settings.json").write_text("{}", encoding="utf-8")
|
||||
(instance / "UserData" / "BeatLeader" / "Replays").mkdir(parents=True)
|
||||
(instance / "UserData" / "BeatLeader" / "Replays" / "big.bsor").write_text("replay", encoding="utf-8")
|
||||
(instance / "UserData" / "ScoreSaber" / "Replays").mkdir(parents=True)
|
||||
(instance / "UserData" / "ScoreSaber" / "Replays" / "big.bsor").write_text("replay", encoding="utf-8")
|
||||
(instance / "UserData" / "BeatSaberPlus" / "Cache").mkdir(parents=True)
|
||||
(instance / "UserData" / "BeatSaberPlus" / "Cache" / "cached.dat").write_text("cache", encoding="utf-8")
|
||||
(instance / "UserData" / "BeatSaverNotifier.json").write_text('{"refreshToken":"secret"}', encoding="utf-8")
|
||||
(instance / "UserData" / "Accsaber").mkdir(parents=True)
|
||||
(instance / "UserData" / "Accsaber" / "PlayerScoreCache.json").write_text("{}", encoding="utf-8")
|
||||
appdata.mkdir(parents=True)
|
||||
(appdata / "Player.log").write_text("log", encoding="utf-8")
|
||||
(appdata / "settings.cfg").write_text("settings", encoding="utf-8")
|
||||
|
||||
result = sync_windows_data_repo(
|
||||
instance="1.44.1",
|
||||
instance_path=instance,
|
||||
backup_root=backup_repo,
|
||||
)
|
||||
|
||||
self.assertEqual(result["backupRoot"], str(backup_repo))
|
||||
self.assertEqual((backup_repo / "UserData" / "settings.json").read_text(), "{}")
|
||||
self.assertFalse((backup_repo / "UserData" / "BeatLeader" / "Replays").exists())
|
||||
self.assertFalse((backup_repo / "UserData" / "ScoreSaber" / "Replays").exists())
|
||||
self.assertFalse((backup_repo / "UserData" / "BeatSaberPlus" / "Cache").exists())
|
||||
self.assertFalse((backup_repo / "UserData" / "BeatSaverNotifier.json").exists())
|
||||
self.assertFalse((backup_repo / "UserData" / "Accsaber" / "PlayerScoreCache.json").exists())
|
||||
self.assertFalse((backup_repo / "AppData" / "Player.log").exists())
|
||||
self.assertEqual((backup_repo / "AppData" / "settings.cfg").read_text(), "settings")
|
||||
descriptor = json.loads((backup_repo / "backup-descriptor.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(descriptor["instance"], "1.44.1")
|
||||
self.assertEqual(descriptor["sources"][0]["source"], str(instance / "UserData"))
|
||||
self.assertIn("BeatLeader/Replays", descriptor["skipped"])
|
||||
self.assertIn("*.log", descriptor["excludePatterns"])
|
||||
|
||||
def test_check_reports_missing_asset(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
@@ -239,6 +517,184 @@ class PluginHelperTests(unittest.TestCase):
|
||||
self.assertEqual(result["summary"]["errors"], 1)
|
||||
self.assertEqual(result["plugins"][0]["status"], "error")
|
||||
|
||||
def test_installed_plugins_report_includes_locked_version(self) -> None:
|
||||
registry = Registry(
|
||||
{
|
||||
"example": RegistryPlugin(
|
||||
id="example",
|
||||
name="Example Plugin",
|
||||
repo="owner/example",
|
||||
install_strategy="dll-to-plugins",
|
||||
)
|
||||
}
|
||||
)
|
||||
lockfile = Lockfile(
|
||||
beat_saber_version="1.40.8",
|
||||
instance="1.40.8",
|
||||
plugins=(
|
||||
LockedPlugin(
|
||||
id="example",
|
||||
repo="owner/example",
|
||||
tag="v1.2.3",
|
||||
asset="Example.dll",
|
||||
sha256="abc123",
|
||||
),
|
||||
),
|
||||
)
|
||||
report = installed_plugins_report(
|
||||
installed_state={
|
||||
"instance": "1.40.8",
|
||||
"plugins": {
|
||||
"example": {
|
||||
"installedAt": "2026-06-14T17:18:40Z",
|
||||
"files": [{"path": "Plugins/Example.dll"}],
|
||||
}
|
||||
},
|
||||
},
|
||||
registry=registry,
|
||||
lockfile=lockfile,
|
||||
)
|
||||
|
||||
self.assertEqual(report["plugins"][0]["name"], "Example Plugin")
|
||||
self.assertEqual(report["plugins"][0]["version"], "v1.2.3")
|
||||
self.assertEqual(report["plugins"][0]["asset"], "Example.dll")
|
||||
self.assertEqual(report["plugins"][0]["fileCount"], 1)
|
||||
|
||||
def test_update_check_reports_current_matching_asset(self) -> None:
|
||||
registry = Registry(
|
||||
{
|
||||
"example": RegistryPlugin(
|
||||
id="example",
|
||||
name="Example",
|
||||
repo="owner/example",
|
||||
asset_patterns=("1.40.8.zip",),
|
||||
install_strategy="bsipa-zip",
|
||||
)
|
||||
}
|
||||
)
|
||||
lockfile = Lockfile(
|
||||
beat_saber_version="1.40.8",
|
||||
instance="1.40.8",
|
||||
plugins=(
|
||||
LockedPlugin(
|
||||
id="example",
|
||||
repo="owner/example",
|
||||
tag="v1.1.0",
|
||||
asset="1.40.8.zip",
|
||||
sha256="abc123",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
result = check_updates(
|
||||
registry=registry,
|
||||
lockfile=lockfile,
|
||||
fetch_releases=lambda repo: [
|
||||
{
|
||||
"tag_name": "v1.1.0",
|
||||
"published_at": "2026-06-10T00:00:00Z",
|
||||
"assets": [{"name": "1.40.8.zip", "digest": "sha256:abc123"}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["current"], 1)
|
||||
self.assertEqual(result["plugins"][0]["status"], "current")
|
||||
self.assertEqual(result["plugins"][0]["latestAssetSha256"], "abc123")
|
||||
|
||||
def test_update_check_reports_new_matching_release(self) -> None:
|
||||
registry = Registry(
|
||||
{
|
||||
"example": RegistryPlugin(
|
||||
id="example",
|
||||
name="Example",
|
||||
repo="owner/example",
|
||||
asset_patterns=("*.zip",),
|
||||
install_strategy="bsipa-zip",
|
||||
)
|
||||
}
|
||||
)
|
||||
lockfile = Lockfile(
|
||||
beat_saber_version="1.40.8",
|
||||
instance="1.40.8",
|
||||
plugins=(
|
||||
LockedPlugin(
|
||||
id="example",
|
||||
repo="owner/example",
|
||||
tag="v1.1.0",
|
||||
asset="1.40.8.zip",
|
||||
sha256="abc123",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
result = check_updates(
|
||||
registry=registry,
|
||||
lockfile=lockfile,
|
||||
fetch_releases=lambda repo: [
|
||||
{
|
||||
"tag_name": "v1.2.0",
|
||||
"published_at": "2026-06-12T00:00:00Z",
|
||||
"assets": [
|
||||
{"name": "1.29.1.zip"},
|
||||
{"name": "1.40.8.zip", "browser_download_url": "https://example.invalid/asset"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"tag_name": "v1.1.0",
|
||||
"published_at": "2026-06-10T00:00:00Z",
|
||||
"assets": [{"name": "1.40.8.zip"}],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["updates"], 1)
|
||||
self.assertEqual(result["plugins"][0]["status"], "update")
|
||||
self.assertEqual(result["plugins"][0]["latestTag"], "v1.2.0")
|
||||
self.assertEqual(result["plugins"][0]["latestAsset"], "1.40.8.zip")
|
||||
|
||||
def test_update_check_reports_replaced_asset_digest(self) -> None:
|
||||
registry = Registry(
|
||||
{
|
||||
"example": RegistryPlugin(
|
||||
id="example",
|
||||
name="Example",
|
||||
repo="owner/example",
|
||||
asset_patterns=("1.40.8.zip",),
|
||||
install_strategy="bsipa-zip",
|
||||
)
|
||||
}
|
||||
)
|
||||
lockfile = Lockfile(
|
||||
beat_saber_version="1.40.8",
|
||||
instance="1.40.8",
|
||||
plugins=(
|
||||
LockedPlugin(
|
||||
id="example",
|
||||
repo="owner/example",
|
||||
tag="v1.1.0",
|
||||
asset="1.40.8.zip",
|
||||
sha256="old",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
result = check_updates(
|
||||
registry=registry,
|
||||
lockfile=lockfile,
|
||||
fetch_releases=lambda repo: [
|
||||
{
|
||||
"tag_name": "v1.1.0",
|
||||
"published_at": "2026-06-10T00:00:00Z",
|
||||
"assets": [{"name": "1.40.8.zip", "digest": "sha256:new"}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["updates"], 1)
|
||||
self.assertEqual(result["plugins"][0]["status"], "update")
|
||||
self.assertEqual(result["plugins"][0]["latestAssetSha256"], "new")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user