Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17bd736e59 | |||
| f085bbb802 | |||
| 8ad2a3dd35 | |||
| 158bc23298 | |||
| 7639fb7270 | |||
| 931c1d4f73 | |||
| 5a9e873de4 |
@@ -1,15 +1,19 @@
|
|||||||
---
|
---
|
||||||
name: install-beatsaber-plugin
|
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
|
# 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
|
## 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.
|
- If the prompt does not contain a GitHub release URL, stop and ask the user for it.
|
||||||
- Do not search the web to discover a repository or "correct" release URL.
|
- Do not 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 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.
|
- 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:
|
Accepted URL shapes include:
|
||||||
|
|
||||||
```text
|
```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' src/plugin_helper/models.py
|
||||||
sed -n '1,220p' registry/plugins.toml
|
sed -n '1,220p' registry/plugins.toml
|
||||||
sed -n '1,220p' locks/<instance>.lock.toml
|
sed -n '1,220p' locks/<instance>.lock.toml
|
||||||
|
sed -n '1,220p' docs/SMOKETEST.md
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Determine the instance.
|
3. Determine the instance.
|
||||||
@@ -54,21 +70,42 @@ https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
|
|||||||
PYTHONPATH=src python -m plugin_helper instances
|
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
|
```bash
|
||||||
mkdir -p "$HOME/archive/beatsaber"
|
python - <<'PY'
|
||||||
tar -C "<instance-root>/<instance>" -czf "$HOME/archive/beatsaber/<instance>-Plugins-pre-helper-<timestamp>.tar.gz" Plugins
|
import json, urllib.request
|
||||||
sha256sum "$HOME/archive/beatsaber/<instance>-Plugins-pre-helper-<timestamp>.tar.gz"
|
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
|
```bash
|
||||||
curl -sS https://api.github.com/repos/<owner>/<repo>/releases
|
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.
|
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:
|
Download to the helper state directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p .state/instances/<instance>/downloads
|
mkdir -p .state/instances/<instance>/downloads/<plugin-id>
|
||||||
curl -L --fail -o .state/instances/<instance>/downloads/<asset-name> "<browser_download_url>"
|
curl -L --fail -o .state/instances/<instance>/downloads/<plugin-id>/<asset-name> "<browser_download_url>"
|
||||||
sha256sum .state/instances/<instance>/downloads/<asset-name>
|
sha256sum .state/instances/<instance>/downloads/<plugin-id>/<asset-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
Match the checksum against GitHub's `digest` when available. Inspect zip contents:
|
Match the checksum against GitHub's `digest` when available. Inspect zip contents:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
unzip -l .state/instances/<instance>/downloads/<asset-name>
|
unzip -l .state/instances/<instance>/downloads/<plugin-id>/<asset-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
Strategy guide:
|
Strategy guide:
|
||||||
|
|
||||||
- `dll-to-plugins`: asset is a single `.dll` that belongs in `Plugins/`.
|
- `dll-to-plugins`: asset is a single `.dll` that belongs in `Plugins/`.
|
||||||
- `bsipa-zip`: zip top-level paths are only `IPA/`, `Libs/`, or `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/`.
|
- `zip-to-pending`: only when the release is intended for `IPA/Pending/`.
|
||||||
- `manual`: do not use for installable releases.
|
- `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:
|
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.
|
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:
|
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.
|
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:
|
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.
|
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:
|
Include:
|
||||||
|
|
||||||
- release URL/tag/asset used
|
- release URL/tag/asset used
|
||||||
- snapshot path and hash, if created
|
|
||||||
- files changed in the repo and helper state
|
- files changed in the repo and helper state
|
||||||
- live instance files changed
|
- live instance files changed
|
||||||
- backup path created by the helper
|
- 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
|
||||||
|
|
||||||
`plugin-helper` is an early Python CLI for managing Beat Saber plugins in a mounted Windows BSManager install.
|
`plugin-helper` is an early Python CLI for managing Beat Saber plugins in BSManager installs.
|
||||||
|
|
||||||
The first implementation focuses on safe local workflows:
|
The first implementation focuses on safe local workflows:
|
||||||
|
|
||||||
@@ -8,30 +8,101 @@ The first implementation focuses on safe local workflows:
|
|||||||
- scan existing `Plugins/` and `Libs/` files
|
- scan existing `Plugins/` and `Libs/` files
|
||||||
- read checked-in registry and per-version lockfiles
|
- read checked-in registry and per-version lockfiles
|
||||||
- generate a machine-readable install plan from local release assets
|
- generate a machine-readable install plan from local release assets
|
||||||
- apply exactly that plan with backups and install state
|
- apply exactly that plan and record install state
|
||||||
- uninstall only files recorded in install state
|
- uninstall only files recorded in install state
|
||||||
- back up `UserData` separately
|
|
||||||
|
|
||||||
Default BSManager instance root:
|
Default BSManager instance roots:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/home/pleb/Windows/Users/pleb/BSManager/BSInstances
|
/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
|
```sh
|
||||||
python -m plugin_helper instances
|
PYTHONPATH=src python -m plugin_helper --state-dir .state menu
|
||||||
python -m plugin_helper scan --instance 1.40.8
|
|
||||||
python -m plugin_helper plan --instance 1.40.8 --state-dir .state
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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:
|
Install assets are currently expected to already exist locally, usually under:
|
||||||
|
|
||||||
```text
|
```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
|
installed.json
|
||||||
plans/
|
plans/
|
||||||
downloads/
|
downloads/
|
||||||
|
<plugin-id>/
|
||||||
backups/
|
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.
|
The registry describes plugin sources and install behavior. It should be human-editable because many Beat Saber plugins have small packaging differences.
|
||||||
|
|
||||||
|
Artifact source preference:
|
||||||
|
|
||||||
|
1. Prefer upstream GitHub release artifacts for normal plugins.
|
||||||
|
2. Use BeatMods as compatibility, dependency, and verification metadata even
|
||||||
|
when the artifact comes from GitHub.
|
||||||
|
3. Use BeatMods CDN artifacts only when the upstream artifact is inaccessible,
|
||||||
|
the package is effectively BeatMods-only, or the package is a framework or
|
||||||
|
library dependency that does not have a normal plugin release source.
|
||||||
|
4. Record both the artifact source and any BeatMods `modVersion`/version-id/
|
||||||
|
`zipHash` metadata used to justify compatibility.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
|||||||
+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.
|
- Python owns instance discovery, dry-run plans, activation, install state, uninstall, and `UserData` backups.
|
||||||
- Release assets are selected through registry and lockfile data.
|
- Release assets are selected through registry and lockfile data.
|
||||||
|
- Prefer upstream GitHub release artifacts for normal plugins. Use BeatMods as
|
||||||
|
compatibility/dependency metadata, and as an artifact source only for
|
||||||
|
inaccessible upstream artifacts, BeatMods-only packages, or framework/library
|
||||||
|
dependencies.
|
||||||
- Mutating operations apply an explicit plan and record exact file hashes.
|
- Mutating operations apply an explicit plan and record exact file hashes.
|
||||||
- Nix packages `plugin-helper`, but does not directly manage the mutable Beat Saber tree.
|
- Nix packages `plugin-helper`, but does not directly manage the mutable Beat Saber tree.
|
||||||
|
|
||||||
@@ -64,12 +68,25 @@ All modes should still produce the same kind of explicit plan before applying.
|
|||||||
## Proposed Milestones
|
## Proposed Milestones
|
||||||
|
|
||||||
1. Keep the Python safety harness stable: scan, plan, apply, uninstall, and backups.
|
1. Keep the Python safety harness stable: scan, plan, apply, uninstall, and backups.
|
||||||
2. Model one real plugin end to end with the current TOML lockfile and local asset planning.
|
2. Model BSIPA bootstrap as a first-class install phase, preferring upstream GitHub release artifacts while preserving BeatMods `zipHash`/version metadata when used for verification or fallback.
|
||||||
3. Add a Nix function that fetches and unpacks one locked plugin asset into a normalized tree.
|
3. Resolve BeatMods dependency closures by mod-version id for verified mods before ordinary batch planning, but keep artifact sourcing GitHub-preferred.
|
||||||
4. Generate a full plugin-set derivation for one Beat Saber version.
|
4. Model one real plugin end to end with the current TOML lockfile and local asset planning.
|
||||||
5. Teach `plugin-helper plan` to compare a Nix output tree against an instance.
|
5. Add a Nix function that fetches and unpacks one locked plugin asset into a normalized tree.
|
||||||
6. Add `--activation-mode copy|symlink|materialize`.
|
6. Generate a full plugin-set derivation for one Beat Saber version.
|
||||||
7. Move compatibility and dependency metadata toward shared data that both Python and Nix can consume.
|
7. Teach `plugin-helper plan` to compare a Nix output tree against an instance.
|
||||||
|
8. Add `--activation-mode copy|symlink|materialize`.
|
||||||
|
9. Move compatibility and dependency metadata toward shared data that both Python and Nix can consume.
|
||||||
|
|
||||||
|
## Warning Follow-Ups From 1.44.1 Bootstrap
|
||||||
|
|
||||||
|
The first 1.44.1 BSIPA/SongCore smoketest worked, but it produced warnings worth tracking separately from install success:
|
||||||
|
|
||||||
|
- BSML, SiraUtil, and SongCore have older target game-version metadata even though BeatMods verifies the selected releases for 1.44.1. Decide whether plugin-helper should treat BeatMods verification as a compatibility override.
|
||||||
|
- The first bootstrap used BeatMods CDN artifacts for speed. BSIPA, BSML, and SiraUtil have now been matched to byte-identical upstream GitHub release assets. SongCore remains a BeatMods CDN fallback because the BeatMods preferred repo `Kylemc1413/SongCore` currently exposes no matching 3.16.0 GitHub release asset.
|
||||||
|
- BSML reports missing Windows fonts under Proton. This is likely cosmetic, but may affect Unicode text rendering in mod UI.
|
||||||
|
- SongCore warns that `Beat Saber_Data/CustomWIPLevels/Cache` has no `Info.dat`. Either create the expected cache directory shape or classify this warning as harmless.
|
||||||
|
- SongCore could not read the audio rate for the built-in `Magic.wav` custom level and approximated duration from map length. Check whether this is a bundled-song oddity or a broader audio metadata issue.
|
||||||
|
- The smoketest launcher can leave Beat Saber running after timeout. Prefer explicit teardown and consider a helper command that starts, watches logs, and kills the process tree deterministically.
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
asset_patterns = ["1.40.8.zip"]
|
||||||
install_strategy = "bsipa-zip"
|
install_strategy = "bsipa-zip"
|
||||||
category = "leaderboard"
|
category = "leaderboard"
|
||||||
|
|
||||||
|
[[plugins]]
|
||||||
|
id = "bsipa"
|
||||||
|
name = "BSIPA"
|
||||||
|
repo = "nike4613/BeatSaber-IPA-Reloaded"
|
||||||
|
asset_patterns = ["BSIPA-net472-x64.zip"]
|
||||||
|
install_strategy = "root-zip"
|
||||||
|
category = "core"
|
||||||
|
|
||||||
|
[[plugins]]
|
||||||
|
id = "beatsabermarkuplanguage"
|
||||||
|
name = "BeatSaberMarkupLanguage"
|
||||||
|
repo = "monkeymanboy/BeatSaberMarkupLanguage"
|
||||||
|
asset_patterns = ["*RELEASE.zip"]
|
||||||
|
install_strategy = "bsipa-zip"
|
||||||
|
category = "library"
|
||||||
|
|
||||||
|
[[plugins.dependencies]]
|
||||||
|
id = "bsipa"
|
||||||
|
constraint = ">=4.3.7"
|
||||||
|
required = true
|
||||||
|
|
||||||
|
[[plugins]]
|
||||||
|
id = "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:
|
if not locked.asset:
|
||||||
messages.append({"level": "error", "message": "missing asset"})
|
messages.append({"level": "error", "message": "missing asset"})
|
||||||
else:
|
else:
|
||||||
asset_path = _find_asset(locked.asset, state_root, instance, repo_root)
|
asset_path = _find_asset(locked.asset, state_root, instance, repo_root, locked.id)
|
||||||
if not asset_path:
|
if not asset_path:
|
||||||
messages.append({"level": "error", "message": "asset not found in downloads or repo assets"})
|
messages.append({"level": "error", "message": "asset not found in downloads or repo assets"})
|
||||||
elif locked.sha256 and sha256_file(asset_path) != locked.sha256:
|
elif locked.sha256 and sha256_file(asset_path) != locked.sha256:
|
||||||
|
|||||||
+361
-16
@@ -4,23 +4,122 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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 .checker import check_lock
|
||||||
|
from .github import fetch_releases
|
||||||
from .installer import apply_plan, uninstall_plugin
|
from .installer import apply_plan, uninstall_plugin
|
||||||
from .instances import get_instance, list_instances
|
from .instances import get_instance, list_instances
|
||||||
from .models import load_lockfile, load_registry
|
from .models import load_lockfile, load_registry
|
||||||
|
from .models import Lockfile, Registry
|
||||||
from .planner import create_plan
|
from .planner import create_plan
|
||||||
from .scanner import scan_instance
|
from .scanner import scan_instance
|
||||||
from .state import load_installed_state
|
from .state import load_installed_state
|
||||||
from .userdata import backup_userdata
|
from .updates import check_updates
|
||||||
|
from .userdata import sync_windows_data_repo
|
||||||
|
|
||||||
|
|
||||||
def _json(data: Any) -> None:
|
def _json(data: Any) -> None:
|
||||||
print(json.dumps(data, indent=2, sort_keys=True))
|
print(json.dumps(data, indent=2, sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
def 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:
|
def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = False) -> None:
|
||||||
default = argparse.SUPPRESS if suppress_default else None
|
default = argparse.SUPPRESS if suppress_default else None
|
||||||
parser.add_argument("--instances-root", default=default, help="BSManager instances root")
|
parser.add_argument("--instances-root", default=default, help="BSManager instances root")
|
||||||
@@ -38,6 +137,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
parents=[_common_parent()],
|
parents=[_common_parent()],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
subcommands.add_parser(
|
||||||
|
"menu",
|
||||||
|
help="Open an interactive instance/action menu",
|
||||||
|
parents=[_common_parent()],
|
||||||
|
)
|
||||||
|
|
||||||
scan = subcommands.add_parser(
|
scan = subcommands.add_parser(
|
||||||
"scan",
|
"scan",
|
||||||
help="Inspect installed Plugins, Libs, and IPA/Pending files",
|
help="Inspect installed Plugins, Libs, and IPA/Pending files",
|
||||||
@@ -54,6 +159,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
)
|
)
|
||||||
state.add_argument("--instance", required=True)
|
state.add_argument("--instance", required=True)
|
||||||
|
|
||||||
|
installed = subcommands.add_parser(
|
||||||
|
"installed",
|
||||||
|
help="List plugins installed by plugin-helper with locked release versions",
|
||||||
|
parents=[_common_parent()],
|
||||||
|
)
|
||||||
|
installed.add_argument("--instance", required=True)
|
||||||
|
installed.add_argument("--registry", default="registry/plugins.toml")
|
||||||
|
installed.add_argument("--lockfile")
|
||||||
|
installed.add_argument("--json", action="store_true", help="Print full JSON output")
|
||||||
|
|
||||||
check = subcommands.add_parser(
|
check = subcommands.add_parser(
|
||||||
"check",
|
"check",
|
||||||
help="Validate local registry, lockfile, and release asset readiness",
|
help="Validate local registry, lockfile, and release asset readiness",
|
||||||
@@ -64,6 +179,37 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
check.add_argument("--lockfile")
|
check.add_argument("--lockfile")
|
||||||
check.add_argument("--json", action="store_true", help="Print full JSON check output")
|
check.add_argument("--json", action="store_true", help="Print full JSON check output")
|
||||||
|
|
||||||
|
bootstrap = subcommands.add_parser(
|
||||||
|
"bootstrap",
|
||||||
|
help="Install locked BSIPA, run IPA.exe -n through Proton, and record bootstrap files",
|
||||||
|
parents=[_common_parent()],
|
||||||
|
)
|
||||||
|
bootstrap.add_argument("--instance", required=True)
|
||||||
|
bootstrap.add_argument("--registry", default="registry/plugins.toml")
|
||||||
|
bootstrap.add_argument("--lockfile")
|
||||||
|
bootstrap.add_argument("--proton", help="Path to Proton executable")
|
||||||
|
bootstrap.add_argument("--json", action="store_true", help="Print full JSON bootstrap output")
|
||||||
|
|
||||||
|
bootstrap_check = subcommands.add_parser(
|
||||||
|
"bootstrap-check",
|
||||||
|
help="Verify recorded BSIPA bootstrap state and latest IPA log",
|
||||||
|
parents=[_common_parent()],
|
||||||
|
)
|
||||||
|
bootstrap_check.add_argument("--instance", required=True)
|
||||||
|
bootstrap_check.add_argument("--json", action="store_true", help="Print full JSON bootstrap health output")
|
||||||
|
|
||||||
|
updates = subcommands.add_parser(
|
||||||
|
"updates",
|
||||||
|
help="Check GitHub for newer matching releases for locked plugins",
|
||||||
|
parents=[_common_parent()],
|
||||||
|
)
|
||||||
|
updates.add_argument("--instance", required=True)
|
||||||
|
updates.add_argument("--registry", default="registry/plugins.toml")
|
||||||
|
updates.add_argument("--lockfile")
|
||||||
|
updates.add_argument("--plugin", action="append", help="Check only this locked plugin id; repeatable")
|
||||||
|
updates.add_argument("--include-prerelease", action="store_true", help="Include prerelease GitHub releases")
|
||||||
|
updates.add_argument("--json", action="store_true", help="Print full JSON update output")
|
||||||
|
|
||||||
plan = subcommands.add_parser(
|
plan = subcommands.add_parser(
|
||||||
"plan",
|
"plan",
|
||||||
help="Create a dry-run install plan from registry and lockfile",
|
help="Create a dry-run install plan from registry and lockfile",
|
||||||
@@ -92,10 +238,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
backup = subcommands.add_parser(
|
backup = subcommands.add_parser(
|
||||||
"backup-userdata",
|
"backup-userdata",
|
||||||
help="Create a timestamped UserData backup archive",
|
help="Copy UserData and Windows AppData into the adjacent backups repo",
|
||||||
parents=[_common_parent()],
|
parents=[_common_parent()],
|
||||||
)
|
)
|
||||||
backup.add_argument("--instance", required=True)
|
backup.add_argument("--instance", required=True)
|
||||||
|
backup.add_argument("--backup-root", default="../backups/beat-saber", help="Backup directory")
|
||||||
|
backup.add_argument("--appdata-path", help="Override Beat Saber Windows AppData path")
|
||||||
|
backup.add_argument("--no-appdata", action="store_true", help="Only copy UserData")
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
@@ -106,17 +255,115 @@ def _common_parent() -> argparse.ArgumentParser:
|
|||||||
return parent
|
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:
|
def run(argv: list[str] | None = None) -> int:
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
inst_root = instances_root(getattr(args, "instances_root", None))
|
inst_roots = instances_roots(getattr(args, "instances_root", None))
|
||||||
st_root = state_root(getattr(args, "state_dir", None))
|
st_root = state_root(getattr(args, "state_dir", None))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if args.command == "instances":
|
if args.command == "instances":
|
||||||
found = list_instances(inst_root)
|
found = list_instances(inst_roots)
|
||||||
if not found:
|
if not found:
|
||||||
print(f"No Beat Saber instances found under {inst_root}")
|
print(f"No Beat Saber instances found under {', '.join(str(root) for root in inst_roots)}")
|
||||||
return 1
|
return 1
|
||||||
for item in found:
|
for item in found:
|
||||||
flags = []
|
flags = []
|
||||||
@@ -130,8 +377,11 @@ def run(argv: list[str] | None = None) -> int:
|
|||||||
print(f"{item.name}\t{item.path}{suffix}")
|
print(f"{item.name}\t{item.path}{suffix}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if args.command == "menu":
|
||||||
|
return _run_menu(inst_roots, st_root)
|
||||||
|
|
||||||
if args.command == "scan":
|
if args.command == "scan":
|
||||||
instance = get_instance(inst_root, args.instance)
|
instance = get_instance(inst_roots, args.instance)
|
||||||
result = scan_instance(instance.path, include_hashes=args.hashes)
|
result = scan_instance(instance.path, include_hashes=args.hashes)
|
||||||
if args.json:
|
if args.json:
|
||||||
_json(result)
|
_json(result)
|
||||||
@@ -147,6 +397,23 @@ def run(argv: list[str] | None = None) -> int:
|
|||||||
_json(load_installed_state(st_root, args.instance))
|
_json(load_installed_state(st_root, args.instance))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if args.command == "installed":
|
||||||
|
root = repo_root()
|
||||||
|
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
|
||||||
|
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
|
||||||
|
if not lock_path.is_absolute():
|
||||||
|
lock_path = (root / lock_path).resolve()
|
||||||
|
result = installed_plugins_report(
|
||||||
|
installed_state=load_installed_state(st_root, args.instance),
|
||||||
|
registry=load_registry(registry_path),
|
||||||
|
lockfile=load_lockfile(lock_path),
|
||||||
|
)
|
||||||
|
if args.json:
|
||||||
|
_json(result)
|
||||||
|
else:
|
||||||
|
print_installed_plugins(result)
|
||||||
|
return 0
|
||||||
|
|
||||||
if args.command == "check":
|
if args.command == "check":
|
||||||
root = repo_root()
|
root = repo_root()
|
||||||
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
|
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
|
||||||
@@ -176,8 +443,76 @@ def run(argv: list[str] | None = None) -> int:
|
|||||||
print(f" {message['level']}: {message['message']}")
|
print(f" {message['level']}: {message['message']}")
|
||||||
return 2 if result["summary"]["errors"] else 0
|
return 2 if result["summary"]["errors"] else 0
|
||||||
|
|
||||||
|
if args.command == "updates":
|
||||||
|
root = repo_root()
|
||||||
|
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
|
||||||
|
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
|
||||||
|
if not lock_path.is_absolute():
|
||||||
|
lock_path = (root / lock_path).resolve()
|
||||||
|
result = check_updates(
|
||||||
|
registry=load_registry(registry_path),
|
||||||
|
lockfile=load_lockfile(lock_path),
|
||||||
|
fetch_releases=fetch_releases,
|
||||||
|
selected=set(args.plugin) if args.plugin else None,
|
||||||
|
include_prerelease=args.include_prerelease,
|
||||||
|
)
|
||||||
|
if args.json:
|
||||||
|
_json(result)
|
||||||
|
else:
|
||||||
|
print_updates(result)
|
||||||
|
return 2 if result["summary"]["errors"] else 0
|
||||||
|
|
||||||
|
if args.command == "bootstrap":
|
||||||
|
instance = get_instance(inst_roots, args.instance)
|
||||||
|
root = repo_root()
|
||||||
|
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
|
||||||
|
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
|
||||||
|
if not lock_path.is_absolute():
|
||||||
|
lock_path = (root / lock_path).resolve()
|
||||||
|
lockfile = load_lockfile(lock_path)
|
||||||
|
result = run_bootstrap(
|
||||||
|
instance=args.instance,
|
||||||
|
instance_path=instance.path,
|
||||||
|
beat_saber_version=lockfile.beat_saber_version,
|
||||||
|
registry=load_registry(registry_path),
|
||||||
|
lockfile=lockfile,
|
||||||
|
state_root=st_root,
|
||||||
|
repo_root=root,
|
||||||
|
proton=Path(args.proton).expanduser() if args.proton else None,
|
||||||
|
progress=lambda message: print(f" {message}", flush=True),
|
||||||
|
)
|
||||||
|
if args.json:
|
||||||
|
_json(result)
|
||||||
|
else:
|
||||||
|
delta = result["delta"]
|
||||||
|
print(f"Bootstrap state: {result['statePath']}")
|
||||||
|
print(f"Plan: {result['planPath']}")
|
||||||
|
print(f"IPA.exe -n exit: {result['ipaExitCode']}")
|
||||||
|
print(
|
||||||
|
"Bootstrap files: "
|
||||||
|
f"{len(delta['created'])} created, {len(delta['mutated'])} mutated, "
|
||||||
|
f"{len(delta['removed'])} removed"
|
||||||
|
)
|
||||||
|
print(f"Health: {'ok' if result['health']['ok'] else 'error'}")
|
||||||
|
for message in result["health"]["messages"]:
|
||||||
|
print(f" {message}")
|
||||||
|
return 0 if result["health"]["ok"] else 2
|
||||||
|
|
||||||
|
if args.command == "bootstrap-check":
|
||||||
|
instance = get_instance(inst_roots, args.instance)
|
||||||
|
result = check_bsipa_health(instance.path, st_root, args.instance)
|
||||||
|
if args.json:
|
||||||
|
_json(result)
|
||||||
|
else:
|
||||||
|
print(f"BSIPA bootstrap: {'ok' if result['ok'] else 'error'}")
|
||||||
|
print(f"State: {result['statePath']}")
|
||||||
|
print(f"Log: {result['logPath']}")
|
||||||
|
for message in result["messages"]:
|
||||||
|
print(f" {message}")
|
||||||
|
return 0 if result["ok"] else 2
|
||||||
|
|
||||||
if args.command == "plan":
|
if args.command == "plan":
|
||||||
instance = get_instance(inst_root, args.instance)
|
instance = get_instance(inst_roots, args.instance)
|
||||||
root = repo_root()
|
root = repo_root()
|
||||||
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
|
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
|
||||||
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
|
lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml"
|
||||||
@@ -211,7 +546,7 @@ def run(argv: list[str] | None = None) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
if args.command == "uninstall":
|
if args.command == "uninstall":
|
||||||
instance = get_instance(inst_root, args.instance)
|
instance = get_instance(inst_roots, args.instance)
|
||||||
result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force)
|
result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force)
|
||||||
print(f"Removed: {len(result['removed'])}")
|
print(f"Removed: {len(result['removed'])}")
|
||||||
if result["skipped"]:
|
if result["skipped"]:
|
||||||
@@ -221,12 +556,22 @@ def run(argv: list[str] | None = None) -> int:
|
|||||||
return 0 if result["stateUpdated"] else 2
|
return 0 if result["stateUpdated"] else 2
|
||||||
|
|
||||||
if args.command == "backup-userdata":
|
if args.command == "backup-userdata":
|
||||||
instance = get_instance(inst_root, args.instance)
|
instance = get_instance(inst_roots, args.instance)
|
||||||
result = backup_userdata(args.instance, instance.path, st_root)
|
root = repo_root()
|
||||||
manifest = result["manifest"]
|
backup_root = Path(args.backup_root).expanduser()
|
||||||
print(f"Archive: {result['archive']}")
|
if not backup_root.is_absolute():
|
||||||
print(f"Files: {manifest['fileCount']}")
|
backup_root = (root / backup_root).resolve()
|
||||||
print(f"Bytes: {manifest['totalSize']}")
|
result = sync_windows_data_repo(
|
||||||
|
instance=args.instance,
|
||||||
|
instance_path=instance.path,
|
||||||
|
backup_root=backup_root,
|
||||||
|
appdata_path=Path(args.appdata_path).expanduser() if args.appdata_path else None,
|
||||||
|
include_appdata=not args.no_appdata,
|
||||||
|
)
|
||||||
|
print(f"Backup root: {result['backupRoot']}")
|
||||||
|
for item in result["copied"]:
|
||||||
|
print(f"{item['label']}: {item['fileCount']} files")
|
||||||
|
print(f" {item['source']} -> {item['destination']}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -4,12 +4,21 @@ import os
|
|||||||
from pathlib import Path
|
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:
|
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")
|
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:
|
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 dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -13,6 +14,9 @@ class Instance:
|
|||||||
has_userdata: bool
|
has_userdata: bool
|
||||||
|
|
||||||
|
|
||||||
|
RootInput = Path | Sequence[Path]
|
||||||
|
|
||||||
|
|
||||||
def looks_like_instance(path: Path) -> bool:
|
def looks_like_instance(path: Path) -> bool:
|
||||||
return (
|
return (
|
||||||
(path / "Beat Saber_Data").is_dir()
|
(path / "Beat Saber_Data").is_dir()
|
||||||
@@ -22,7 +26,13 @@ def looks_like_instance(path: Path) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def list_instances(root: Path) -> list[Instance]:
|
def _root_list(root: RootInput) -> list[Path]:
|
||||||
|
if isinstance(root, Path):
|
||||||
|
return [root]
|
||||||
|
return list(root)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_instances_one(root: Path) -> list[Instance]:
|
||||||
if not root.exists():
|
if not root.exists():
|
||||||
return []
|
return []
|
||||||
instances: list[Instance] = []
|
instances: list[Instance] = []
|
||||||
@@ -41,7 +51,20 @@ def list_instances(root: Path) -> list[Instance]:
|
|||||||
return instances
|
return instances
|
||||||
|
|
||||||
|
|
||||||
def get_instance(root: Path, name: str) -> Instance:
|
def list_instances(root: RootInput) -> list[Instance]:
|
||||||
|
instances: list[Instance] = []
|
||||||
|
seen_paths: set[Path] = set()
|
||||||
|
for item in _root_list(root):
|
||||||
|
for instance in _list_instances_one(item):
|
||||||
|
resolved = instance.path.resolve(strict=False)
|
||||||
|
if resolved in seen_paths:
|
||||||
|
continue
|
||||||
|
seen_paths.add(resolved)
|
||||||
|
instances.append(instance)
|
||||||
|
return sorted(instances, key=lambda item: (item.name, str(item.path)))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_instance_one(root: Path, name: str) -> Instance:
|
||||||
path = root / name
|
path = root / name
|
||||||
if not path.is_dir() or not looks_like_instance(path):
|
if not path.is_dir() or not looks_like_instance(path):
|
||||||
raise FileNotFoundError(f"Beat Saber instance not found: {path}")
|
raise FileNotFoundError(f"Beat Saber instance not found: {path}")
|
||||||
@@ -52,3 +75,22 @@ def get_instance(root: Path, name: str) -> Instance:
|
|||||||
has_libs=(path / "Libs").is_dir(),
|
has_libs=(path / "Libs").is_dir(),
|
||||||
has_userdata=(path / "UserData").is_dir(),
|
has_userdata=(path / "UserData").is_dir(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_instance(root: RootInput, name: str) -> Instance:
|
||||||
|
if isinstance(root, Path):
|
||||||
|
return _get_instance_one(root, name)
|
||||||
|
|
||||||
|
matches: list[Instance] = []
|
||||||
|
for item in _root_list(root):
|
||||||
|
try:
|
||||||
|
matches.append(_get_instance_one(item, name))
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
if not matches:
|
||||||
|
searched = ", ".join(str(item) for item in _root_list(root))
|
||||||
|
raise FileNotFoundError(f"Beat Saber instance not found: {name} under {searched}")
|
||||||
|
if len(matches) > 1:
|
||||||
|
paths = ", ".join(str(item.path) for item in matches)
|
||||||
|
raise ValueError(f"Beat Saber instance name is ambiguous: {name}; matches: {paths}")
|
||||||
|
return matches[0]
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from zipfile import ZipFile
|
|||||||
|
|
||||||
from .fsutil import ensure_relative, sha256_bytes, sha256_file
|
from .fsutil import ensure_relative, sha256_bytes, sha256_file
|
||||||
from .models import Lockfile, Registry, VALID_STRATEGIES
|
from .models import Lockfile, Registry, VALID_STRATEGIES
|
||||||
from .state import downloads_dir, plans_dir
|
from .bsipa import BSIPA_PLUGIN_ID, check_bsipa_health
|
||||||
|
from .state import downloads_dir, plans_dir, plugin_downloads_dir
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_BSIPA_TOP_LEVEL = {"IPA", "Libs", "Plugins"}
|
ALLOWED_BSIPA_TOP_LEVEL = {"IPA", "Libs", "Plugins"}
|
||||||
@@ -19,13 +20,15 @@ def _now_slug() -> str:
|
|||||||
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
|
|
||||||
def _find_asset(asset: str, state_root: Path, instance: str, repo_root: Path) -> Path | None:
|
def _find_asset(asset: str, state_root: Path, instance: str, repo_root: Path, plugin_id: str | None = None) -> Path | None:
|
||||||
candidates = [
|
candidates = [
|
||||||
Path(asset).expanduser(),
|
Path(asset).expanduser(),
|
||||||
downloads_dir(state_root, instance) / asset,
|
|
||||||
repo_root / "assets" / asset,
|
repo_root / "assets" / asset,
|
||||||
repo_root / "locks" / "assets" / asset,
|
repo_root / "locks" / "assets" / asset,
|
||||||
]
|
]
|
||||||
|
if plugin_id:
|
||||||
|
candidates.insert(1, plugin_downloads_dir(state_root, instance, plugin_id) / asset)
|
||||||
|
candidates.insert(2 if plugin_id else 1, downloads_dir(state_root, instance) / asset)
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
if candidate.exists() and candidate.is_file():
|
if candidate.exists() and candidate.is_file():
|
||||||
return candidate
|
return candidate
|
||||||
@@ -72,11 +75,20 @@ def create_plan(
|
|||||||
state_root: Path,
|
state_root: Path,
|
||||||
repo_root: Path,
|
repo_root: Path,
|
||||||
selected: set[str] | None = None,
|
selected: set[str] | None = None,
|
||||||
|
require_bootstrap: bool = True,
|
||||||
) -> tuple[dict[str, Any], Path]:
|
) -> tuple[dict[str, Any], Path]:
|
||||||
selected_ids = selected or {plugin.id for plugin in lockfile.plugins}
|
selected_ids = selected or {plugin.id for plugin in lockfile.plugins}
|
||||||
changes: list[dict[str, Any]] = []
|
changes: list[dict[str, Any]] = []
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
has_locked_bsipa = any(plugin.id == BSIPA_PLUGIN_ID for plugin in lockfile.plugins)
|
||||||
|
planning_ordinary_plugins = any(plugin_id != BSIPA_PLUGIN_ID for plugin_id in selected_ids)
|
||||||
|
if require_bootstrap and has_locked_bsipa and planning_ordinary_plugins:
|
||||||
|
health = check_bsipa_health(instance_path, state_root, instance)
|
||||||
|
if not health["ok"]:
|
||||||
|
joined = "; ".join(health["messages"])
|
||||||
|
raise ValueError(f"BSIPA bootstrap is not healthy; run bootstrap first: {joined}")
|
||||||
|
|
||||||
for locked in lockfile.plugins:
|
for locked in lockfile.plugins:
|
||||||
if locked.id not in selected_ids:
|
if locked.id not in selected_ids:
|
||||||
continue
|
continue
|
||||||
@@ -91,10 +103,11 @@ def create_plan(
|
|||||||
if registry_plugin and not _asset_matches_patterns(Path(locked.asset).name, registry_plugin.asset_patterns):
|
if registry_plugin and not _asset_matches_patterns(Path(locked.asset).name, registry_plugin.asset_patterns):
|
||||||
warnings.append(f"{locked.id}: asset does not match registry patterns")
|
warnings.append(f"{locked.id}: asset does not match registry patterns")
|
||||||
|
|
||||||
asset_path = _find_asset(locked.asset, state_root, instance, repo_root)
|
asset_path = _find_asset(locked.asset, state_root, instance, repo_root, locked.id)
|
||||||
if not asset_path:
|
if not asset_path:
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
f"{locked.id}: asset not found: {locked.asset}; put it in {downloads_dir(state_root, instance)}"
|
f"{locked.id}: asset not found: {locked.asset}; put it in "
|
||||||
|
f"{plugin_downloads_dir(state_root, instance, locked.id)}"
|
||||||
)
|
)
|
||||||
asset_sha = sha256_file(asset_path)
|
asset_sha = sha256_file(asset_path)
|
||||||
if locked.sha256 and locked.sha256 != asset_sha:
|
if locked.sha256 and locked.sha256 != asset_sha:
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from .fsutil import sha256_file
|
|||||||
|
|
||||||
|
|
||||||
SCAN_DIRS = ("Plugins", "Libs", "IPA/Pending")
|
SCAN_DIRS = ("Plugins", "Libs", "IPA/Pending")
|
||||||
|
BOOTSTRAP_DIRS = ("Libs", "IPA")
|
||||||
|
BOOTSTRAP_ROOT_GLOBS = ("winhttp.dll", "IPA.exe*")
|
||||||
|
|
||||||
|
|
||||||
def scan_instance(instance_path: Path, include_hashes: bool = False) -> dict[str, Any]:
|
def scan_instance(instance_path: Path, include_hashes: bool = False) -> dict[str, Any]:
|
||||||
@@ -31,3 +33,32 @@ def scan_instance(instance_path: Path, include_hashes: bool = False) -> dict[str
|
|||||||
"pending": sum(1 for item in files if item["path"].startswith("IPA/Pending/")),
|
"pending": sum(1 for item in files if item["path"].startswith("IPA/Pending/")),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_bootstrap_files(instance_path: Path) -> list[dict[str, Any]]:
|
||||||
|
files_by_path: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for pattern in BOOTSTRAP_ROOT_GLOBS:
|
||||||
|
for path in sorted(instance_path.glob(pattern)):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(instance_path).as_posix()
|
||||||
|
files_by_path[rel] = {
|
||||||
|
"path": rel,
|
||||||
|
"size": path.stat().st_size,
|
||||||
|
"sha256": sha256_file(path),
|
||||||
|
}
|
||||||
|
|
||||||
|
for dirname in BOOTSTRAP_DIRS:
|
||||||
|
root = instance_path / dirname
|
||||||
|
if not root.exists():
|
||||||
|
continue
|
||||||
|
for path in sorted(item for item in root.rglob("*") if item.is_file()):
|
||||||
|
rel = path.relative_to(instance_path).as_posix()
|
||||||
|
files_by_path[rel] = {
|
||||||
|
"path": rel,
|
||||||
|
"size": path.stat().st_size,
|
||||||
|
"sha256": sha256_file(path),
|
||||||
|
}
|
||||||
|
|
||||||
|
return [files_by_path[path] for path in sorted(files_by_path)]
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ def installed_state_path(state_root: Path, instance: str) -> Path:
|
|||||||
return instance_state_dir(state_root, instance) / "installed.json"
|
return instance_state_dir(state_root, instance) / "installed.json"
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_state_path(state_root: Path, instance: str) -> Path:
|
||||||
|
return instance_state_dir(state_root, instance) / "bootstrap.json"
|
||||||
|
|
||||||
|
|
||||||
def load_installed_state(state_root: Path, instance: str) -> dict[str, Any]:
|
def load_installed_state(state_root: Path, instance: str) -> dict[str, Any]:
|
||||||
return read_json(
|
return read_json(
|
||||||
installed_state_path(state_root, instance),
|
installed_state_path(state_root, instance),
|
||||||
@@ -22,6 +26,16 @@ def load_installed_state(state_root: Path, instance: str) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_bootstrap_state(state_root: Path, instance: str) -> dict[str, Any]:
|
||||||
|
return read_json(bootstrap_state_path(state_root, instance), {})
|
||||||
|
|
||||||
|
|
||||||
|
def save_bootstrap_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
|
||||||
|
state.setdefault("instance", instance)
|
||||||
|
state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
atomic_write_json(bootstrap_state_path(state_root, instance), state)
|
||||||
|
|
||||||
|
|
||||||
def save_installed_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
|
def save_installed_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
|
||||||
state.setdefault("instance", instance)
|
state.setdefault("instance", instance)
|
||||||
state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
@@ -40,6 +54,12 @@ def downloads_dir(state_root: Path, instance: str) -> Path:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_downloads_dir(state_root: Path, instance: str, plugin_id: str) -> Path:
|
||||||
|
path = downloads_dir(state_root, instance) / plugin_id
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def backups_dir(state_root: Path, instance: str) -> Path:
|
def backups_dir(state_root: Path, instance: str) -> Path:
|
||||||
path = instance_state_dir(state_root, instance) / "backups"
|
path = instance_state_dir(state_root, instance) / "backups"
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import fnmatch
|
||||||
|
import shutil
|
||||||
import tarfile
|
import tarfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
|
|
||||||
from .fsutil import sha256_file
|
from .fsutil import sha256_file
|
||||||
from .state import backups_dir
|
from .state import backups_dir
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_BACKUP_EXCLUDES = (
|
||||||
|
"BeatLeader/Replays",
|
||||||
|
"BeatLeader/Replays/**",
|
||||||
|
"BeatLeader/ReplayerCache",
|
||||||
|
"BeatLeader/ReplayerCache/**",
|
||||||
|
"BeatLeader/LeaderboardsCache",
|
||||||
|
"BeatLeader/LeaderboardsCache/**",
|
||||||
|
"BeatLeader/ReplayHeadersCache",
|
||||||
|
"ScoreSaber/Replays",
|
||||||
|
"ScoreSaber/Replays/**",
|
||||||
|
"BeatSaberPlus/Cache",
|
||||||
|
"BeatSaberPlus/Cache/**",
|
||||||
|
"BeatSaverNotifier.json",
|
||||||
|
"Accsaber/PlayerScoreCache.json",
|
||||||
|
"NalulunaAvatars/cache",
|
||||||
|
"NalulunaAvatars/cache/**",
|
||||||
|
"SongDetailsCache.proto",
|
||||||
|
"com.unity.addressables",
|
||||||
|
"com.unity.addressables/**",
|
||||||
|
"*.log",
|
||||||
|
"*.log.*",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dict[str, Any]:
|
def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dict[str, Any]:
|
||||||
source = instance_path / "UserData"
|
source = instance_path / "UserData"
|
||||||
if not source.is_dir():
|
if not source.is_dir():
|
||||||
@@ -44,3 +70,94 @@ def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dic
|
|||||||
handle.flush()
|
handle.flush()
|
||||||
archive.add(handle.name, arcname="manifest.json")
|
archive.add(handle.name, arcname="manifest.json")
|
||||||
return {"archive": str(destination), "manifest": manifest}
|
return {"archive": str(destination), "manifest": manifest}
|
||||||
|
|
||||||
|
|
||||||
|
def infer_windows_appdata_path(instance_path: Path) -> Path:
|
||||||
|
parts = instance_path.resolve().parts
|
||||||
|
try:
|
||||||
|
users_index = parts.index("Users")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"cannot infer Windows user profile from instance path: {instance_path}") from exc
|
||||||
|
if users_index + 1 >= len(parts):
|
||||||
|
raise ValueError(f"cannot infer Windows user profile from instance path: {instance_path}")
|
||||||
|
profile = Path(*parts[: users_index + 2])
|
||||||
|
return profile / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber"
|
||||||
|
|
||||||
|
|
||||||
|
def 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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
import os
|
||||||
|
from io import StringIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
from zipfile import ZipFile
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
from plugin_helper.bootstrap import _run_ipa
|
||||||
from plugin_helper.checker import check_lock
|
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.fsutil import sha256_file
|
||||||
from plugin_helper.installer import apply_plan, uninstall_plugin
|
from plugin_helper.installer import apply_plan, uninstall_plugin
|
||||||
from plugin_helper.instances import get_instance, list_instances
|
from plugin_helper.instances import get_instance, list_instances
|
||||||
from plugin_helper.models import Lockfile, LockedPlugin, Registry, RegistryPlugin
|
from plugin_helper.models import Lockfile, LockedPlugin, Registry, RegistryPlugin
|
||||||
from plugin_helper.planner import create_plan
|
from plugin_helper.planner import create_plan
|
||||||
from plugin_helper.scanner import scan_instance
|
from plugin_helper.scanner import scan_bootstrap_files, scan_instance
|
||||||
from plugin_helper.state import downloads_dir, load_installed_state
|
from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir, save_bootstrap_state
|
||||||
from plugin_helper.userdata import backup_userdata
|
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):
|
class PluginHelperTests(unittest.TestCase):
|
||||||
@@ -37,6 +44,87 @@ class PluginHelperTests(unittest.TestCase):
|
|||||||
self.assertEqual(scan["files"][0]["path"], "Plugins/Example.dll")
|
self.assertEqual(scan["files"][0]["path"], "Plugins/Example.dll")
|
||||||
self.assertIn("sha256", scan["files"][0])
|
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:
|
def test_plan_apply_and_uninstall_dll(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
work = Path(tmp)
|
work = Path(tmp)
|
||||||
@@ -46,7 +134,7 @@ class PluginHelperTests(unittest.TestCase):
|
|||||||
(instance / "Beat Saber_Data").mkdir()
|
(instance / "Beat Saber_Data").mkdir()
|
||||||
(instance / "Plugins").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")
|
asset.write_bytes(b"managed dll")
|
||||||
|
|
||||||
registry = Registry(
|
registry = Registry(
|
||||||
@@ -103,7 +191,7 @@ class PluginHelperTests(unittest.TestCase):
|
|||||||
state = work / "state"
|
state = work / "state"
|
||||||
instance.mkdir(parents=True)
|
instance.mkdir(parents=True)
|
||||||
(instance / "Beat Saber_Data").mkdir()
|
(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:
|
with ZipFile(asset, "w") as archive:
|
||||||
archive.writestr("Plugins/Example.dll", b"dll")
|
archive.writestr("Plugins/Example.dll", b"dll")
|
||||||
|
|
||||||
@@ -144,6 +232,49 @@ class PluginHelperTests(unittest.TestCase):
|
|||||||
apply_plan(plan, state)
|
apply_plan(plan, state)
|
||||||
self.assertEqual((instance / "IPA" / "Pending" / "Plugins" / "Example.dll").read_bytes(), b"dll")
|
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:
|
def test_zip_member_cannot_escape_instance(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
work = Path(tmp)
|
work = Path(tmp)
|
||||||
@@ -151,7 +282,7 @@ class PluginHelperTests(unittest.TestCase):
|
|||||||
state = work / "state"
|
state = work / "state"
|
||||||
instance.mkdir(parents=True)
|
instance.mkdir(parents=True)
|
||||||
(instance / "Beat Saber_Data").mkdir()
|
(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:
|
with ZipFile(asset, "w") as archive:
|
||||||
archive.writestr("../Bad.dll", b"dll")
|
archive.writestr("../Bad.dll", b"dll")
|
||||||
|
|
||||||
@@ -190,6 +321,103 @@ class PluginHelperTests(unittest.TestCase):
|
|||||||
repo_root=work,
|
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:
|
def test_userdata_backup_contains_manifest(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
@@ -202,6 +430,56 @@ class PluginHelperTests(unittest.TestCase):
|
|||||||
self.assertTrue(Path(result["archive"]).exists())
|
self.assertTrue(Path(result["archive"]).exists())
|
||||||
self.assertEqual(result["manifest"]["fileCount"], 1)
|
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:
|
def test_check_reports_missing_asset(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
work = Path(tmp)
|
work = Path(tmp)
|
||||||
@@ -239,6 +517,184 @@ class PluginHelperTests(unittest.TestCase):
|
|||||||
self.assertEqual(result["summary"]["errors"], 1)
|
self.assertEqual(result["summary"]["errors"], 1)
|
||||||
self.assertEqual(result["plugins"][0]["status"], "error")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user