setlist/docs/pc-modding.md

420 lines
19 KiB
Markdown

# Beat Saber Plugin Development on Linux with Cursor
This guide is a 2026 take on the BSMG "PC Mod Development Intro" wiki, adapted for:
- Cursor IDE (instead of Visual Studio or Rider)
- Linux (instead of Windows)
- LLM-assisted development
The official wiki ([`vs-setup.md`](../../../src/bsmg/wiki/wiki/modding/pc/vs-setup.md),
[`setup.md`](../../../src/bsmg/wiki/wiki/modding/pc/setup.md)) assumes a Visual Studio or
Rider extension (BSMT) does most of the project plumbing for you. None of those
extensions exist for Cursor / VSCode, so we drive the same toolchain by hand
from the `dotnet` CLI. The good news: BSMT's heavy lifting actually lives in an
MSBuild-time NuGet package (`BeatSaberModdingTools.Tasks`), so once a project
is bootstrapped, the IDE is just a code editor.
## TL;DR
A Beat Saber plugin is a .NET Framework 4.7.2 class library that BSIPA loads at
runtime. The compiled `.dll` is platform-agnostic CIL bytecode, so **you can
build it on Linux and Beat Saber (running through Proton) will load it
unmodified**. The workflow is:
1. Install Beat Saber + BSIPA via BSManager (Linux-friendly).
2. Install the .NET SDK and (optionally) Mono on the host.
3. Copy one of the BSMT project templates from
[`UnityModdingTools.Templates.BeatSaber`](../../../src/Zingabopp/UnityModdingTools.Templates.BeatSaber)
into a new repo and substitute the `$placeholders$`.
4. Edit `csproj.user` to point `BeatSaberDir` at your install.
5. `dotnet build` — the `BeatSaberModdingTools.Tasks` MSBuild package generates
the embedded `manifest.json`, copies the DLL into `Beat Saber/Plugins/`, and
(on `Release`) zips it for distribution.
6. Launch Beat Saber from BSManager (or Steam with `--verbose`) and watch the
BSIPA console.
The rest of this doc walks each step in detail.
## 1. Toolchain landscape
| Concern | Windows / VS-Rider workflow | Linux / Cursor workflow |
| -------------------------- | -------------------------------------- | -------------------------------------------------------------------- |
| Project templates | BSMT VSIX or BSMT-Rider plugin | Copy the template files manually from the BSMT template repo |
| `manifest.json` generation | `BeatSaberModdingTools.Tasks` (MSBuild) | Same — pure NuGet, IDE-independent |
| Reference resolution | BSMT "Beat Saber Reference Manager" UI | Hand-edit `<Reference Include="…"><HintPath>` entries in `.csproj` |
| Build | IDE "Build" button (msbuild) | `dotnet build` (or `msbuild` from Mono) |
| Deploy to game | BSMT post-build copy step | Same — driven by `BeatSaberModdingTools.Tasks` |
| Run / debug | VS / Rider attach to `Beat Saber.exe` | Inspect BSIPA console + log files; remote-debug via Mono is possible |
The two key insights that make Cursor-on-Linux viable:
1. **`BeatSaberModdingTools.Tasks` is just a NuGet package.** It hooks into
MSBuild via `build/` targets shipped in the package, so any `dotnet build`
gets the same manifest generation, output copy, and release zipping that
VS / Rider do. See its references in
[`BareProjectTemplate.csproj`](../../../src/Zingabopp/UnityModdingTools.Templates.BeatSaber/BSIPA%20Plugin%20%28Bare%29/BareProjectTemplate.csproj)
and
[`CoreProjectTemplate.csproj`](../../../src/Zingabopp/UnityModdingTools.Templates.BeatSaber/BSIPA%20Plugin%20%28Core%29/CoreProjectTemplate.csproj).
2. **Plugin DLLs are CIL, not native.** Beat Saber's Unity Mono runtime loads
any `net472`-compatible assembly. Whether the assembly was produced by
`csc.exe` on Windows or `dotnet build` on Linux makes no difference at
runtime.
## 2. Install Beat Saber + BSIPA on Linux
Beat Saber has no native Linux binary — it runs through Proton in your Steam
prefix at `~/.local/share/Steam/steamapps/common/Beat Saber/`. The BSMG
[Linux Modding Guide](https://bsmg.wiki/linux-modding.html) lists three
options; **BSManager is what BSMG currently recommends**.
```bash
# 1. Install BSManager (AppImage, .deb, or AUR — see its release page)
# https://github.com/Zagrios/bs-manager/releases
# Linux install notes:
# https://github.com/Zagrios/bs-manager/wiki/Linux#installation
# 2. Launch Beat Saber once via Steam *before* modding it (creates the prefix).
# 3. In BSManager:
# - Download a "Recommended" Beat Saber version into a *managed* copy
# (do NOT mod the Steam copy directly — Steam updates will break mods).
# - Open that version → Mods tab → install at minimum BSIPA.
# - Always launch the modded version from BSManager.
```
If you would rather wire up BSIPA by hand (e.g. for a CI machine), the manual
recipe is:
1. Add `WINEDLLOVERRIDES="winhttp=native,builtin" %command%` to Beat Saber's
Steam launch options.
2. Drop `BSIPA-x64-Net4.zip` into the game folder, then run
`wine IPA.exe -n` from inside it.
3. Verify a `Plugins/` folder is created next to `Beat Saber.exe`.
The full BSIPA install reference is at
<https://nike4613.github.io/BeatSaber-IPA-Reloaded/articles/start-user.html>.
::: tip Pin a known game version
Steam will silently update Beat Saber and break your mod. Either let BSManager
hold a pinned download (preferred) or set the Steam app to "Only update this
game when I launch it" and skip launching from Steam.
:::
## 3. Host toolchain
Install once on the host:
```bash
# Pick whichever your distro provides (examples for the most common ones)
sudo pacman -S dotnet-sdk # Arch
sudo apt install dotnet-sdk-9.0 # Debian/Ubuntu
sudo dnf install dotnet-sdk-9.0 # Fedora
```
Verify:
```bash
dotnet --list-sdks # any 6.0+ SDK can build net472 targets
```
You do **not** need Mono for building. The `dotnet` SDK plus the NuGet
`Microsoft.NETFramework.ReferenceAssemblies` package (pulled in transitively
by `BeatSaberModdingTools.Tasks`) is sufficient to produce `net472` assemblies
on Linux. Install Mono only if you want to run / debug `.NET Framework` test
harnesses outside the game.
Optional but recommended:
- `nuget` CLI (for inspecting / restoring packages outside `dotnet`).
- `ilspycmd` or `ILSpy` for reading decompiled game code.
## 4. Cursor extensions for C\#
Cursor's marketplace doesn't carry Microsoft's official `C#` /
`C# Dev Kit` extensions (their license restricts them to VS Code / VS).
Workable replacements that ship through Open VSX / sideloads:
| Need | Extension |
| ----------------------------- | -------------------------------------------------------- |
| Language server (LSP) | `muhammad-sammy.csharp` (community fork of OmniSharp) |
| or | `Ionide.csharp` / `csharp-language-server` if preferred |
| Debugger | `vsdbg` is MS-licensed; on Linux use `netcoredbg` via |
| | `muhammad-sammy.csharp`'s built-in debugger |
| `.csproj` / MSBuild awareness | Comes with the language-server extension above |
| XAML / `.bsml` | Plain XML support is enough; no dedicated BSML extension |
Install from Cursor: `Ctrl+Shift+X` → search the names above. If a package is
not in the marketplace, grab the `.vsix` from
<https://open-vsx.org/> and use **Install from VSIX…**.
Settings worth tweaking in `settings.json`:
```jsonc
{
// Force OmniSharp/Roslyn to load the right SDK
"dotnet.server.useOmnisharp": true,
// Prevent the language server from chewing on the Beat Saber game DLLs
"files.watcherExclude": {
"**/Refs/**": true,
"**/bin/**": true,
"**/obj/**": true
}
}
```
## 5. Bootstrap a new plugin project
The BSMT templates are designed to be expanded by Visual Studio's templating
engine — they contain `$safeprojectname$`, `$guid1$`, and
`$targetframeworkversion$` placeholders. We expand them by hand. Pick the
template that matches what you want:
| Template | When to use |
| ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| [`BSIPA Plugin (Bare)`](../../../src/Zingabopp/UnityModdingTools.Templates.BeatSaber/BSIPA%20Plugin%20%28Bare%29) | Smallest possible BSIPA plugin — `Plugin.cs` + `manifest.json`. |
| [`BSIPA Plugin (Core)`](../../../src/Zingabopp/UnityModdingTools.Templates.BeatSaber/BSIPA%20Plugin%20%28Core%29) | Adds a `MonoBehaviour` controller and a commented config example. |
| [`BSIPA Plugin (Disableable)`](../../../src/Zingabopp/UnityModdingTools.Templates.BeatSaber/BSIPA%20Plugin%20%28Disableable%29) | Plugin that can be enabled/disabled at runtime. |
The walkthrough below uses **Core** and a fictional plugin called `MyPlugin`.
### 5.1 Copy the template
```bash
SRC="$HOME/src/Zingabopp/UnityModdingTools.Templates.BeatSaber/BSIPA Plugin (Core)"
mkdir -p ~/src/yourname/MyPlugin && cd ~/src/yourname/MyPlugin
cp "$SRC"/Plugin.cs Plugin.cs
cp "$SRC"/MonobehaviourTemplate.cs MyPluginController.cs
cp "$SRC"/PluginConfig.cs Configuration/PluginConfig.cs
mkdir -p Properties
cp "$SRC"/AssemblyInfo.cs Properties/AssemblyInfo.cs
cp "$SRC"/manifest.json manifest.json
cp "$SRC"/CoreProjectTemplate.csproj MyPlugin.csproj
cp "$SRC"/csproj.user.template MyPlugin.csproj.user
cp "$SRC"/Directory.Build.props.template Directory.Build.props
```
### 5.2 Substitute placeholders
The VS template engine would normally do this. Replace these tokens across
every file:
| Token | Replace with |
| ------------------------------ | ------------------------------------------------------------------------- |
| `$safeprojectname$` | `MyPlugin` |
| `$projectname$` | `My Plugin` (display name; can equal the safe name) |
| `$guid1$` | A fresh GUID — `uuidgen` then wrap as `{XXXXXXXX-…}` |
| `$targetframeworkversion$` | `4.7.2` |
| Any reference to `$safeprojectname$Controller` | `MyPluginController` (in `Plugin.cs` + the controller filename) |
A one-shot rewrite:
```bash
GUID="{$(uuidgen | tr a-f A-F)}"
find . -type f \( -name '*.cs' -o -name '*.csproj' -o -name '*.user' -o -name 'manifest.json' \) \
-exec sed -i \
-e "s|\$safeprojectname\$|MyPlugin|g" \
-e "s|\$projectname\$|My Plugin|g" \
-e "s|\$guid1\$|$GUID|g" \
-e "s|\$targetframeworkversion\$|4.7.2|g" {} +
```
### 5.3 Tell MSBuild where Beat Saber lives
Edit `MyPlugin.csproj.user` (this file should be `.gitignore`'d — paths are
machine-specific):
```xml
<Project>
<PropertyGroup>
<BeatSaberDir>/home/you/.local/share/Steam/steamapps/common/Beat Saber</BeatSaberDir>
</PropertyGroup>
</Project>
```
If you mod a BSManager-managed copy instead of the Steam copy, point at that
folder (typically under `~/BSManager/versions/<version>/`). All the
`<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\…</HintPath>` entries in
`MyPlugin.csproj` resolve through that variable. Backslashes work fine — MSBuild
normalises them on Linux.
### 5.4 Fill in your manifest
Open `manifest.json` and set `id`, `name`, `author`, `version`, `description`,
and (importantly) `gameVersion` — the value here is what BSIPA shows the user
when your plugin is loaded against a different game version.
```jsonc
{
"$schema": "https://raw.githubusercontent.com/bsmg/BSIPA-MetadataFileSchema/master/Schema.json",
"id": "MyPlugin",
"name": "My Plugin",
"author": "you",
"version": "0.1.0",
"description": "What this plugin does in one or two sentences.",
"gameVersion": "1.42.3",
"dependsOn": {
"BSIPA": "^4.3.0"
}
}
```
`gameVersion` should match the major.minor.patch of the game you are targeting
(check `BeatSaberVersion.txt` in the install dir — yours is `1.42.3_15380`,
i.e. `1.42.3`). Add other plugins (e.g. `BeatSaberMarkupLanguage`,
`SiraUtil`) to `dependsOn` as needed.
### 5.5 Initial build
```bash
cd ~/src/yourname/MyPlugin
dotnet restore
dotnet build -c Debug
```
The first restore pulls down `BeatSaberModdingTools.Tasks` and the .NET
Framework reference assemblies. After the build succeeds you should see:
- `bin/Debug/MyPlugin.dll` (and `MyPlugin.pdb`).
- A copy of the same DLL inside `<BeatSaberDir>/Plugins/`. This is done by
the BSMT MSBuild target `CopyToPlugins`. Disable it on CI by passing
`-p:DisableCopyToPlugins=True`.
If the build fails complaining about missing `Main.dll`, `HMUI.dll`, etc.,
your `BeatSaberDir` is wrong or pointing at an unmodded game (the Managed
folder must contain those DLLs).
### 5.6 Release builds
```bash
dotnet build -c Release
```
This produces `bin/Release/MyPlugin.dll` plus a distributable
`bin/Release/zip/MyPlugin-<version>.zip` shaped to drop into the game folder.
## 6. Iteration loop
1. Edit C# in Cursor; the OmniSharp-style language server gives you completion
against the game's own DLLs (it follows the `<HintPath>` entries).
2. `dotnet build` — the DLL lands in `<BeatSaberDir>/Plugins/` automatically.
3. Launch Beat Saber from BSManager with the `--verbose --debug` arguments
set under "Advanced launch", or from Steam with the same flags appended to
the launch options. BSManager already exposes a "Debug mode" toggle that
adds `--verbose`.
4. Watch the BSIPA console window or `Logs/_latest.log` in the game folder.
Useful launch arguments (full list in the BSMG
[index page](../../../src/bsmg/wiki/wiki/modding/pc/index.md)):
| Flag | Effect |
| -------------- | ------------------------------------------------------- |
| `--verbose` | Open the BSIPA console window. |
| `--debug` | Promote `Debug.Log(…)` calls to console output. |
| `--trace` | Even noisier — BSIPA-internal traces. |
| `fpfc` | First-Person Flying Controller — play without VR. |
| `--auto_play` | Built-in autoplayer (handy for testing UI/menu mods). |
For a tighter loop, point your Steam compatibility tool at GE-Proton (newer
than the Valve build) and add `PROTON_LOG=1` so Wine logs land in
`~/steam-MyPlugin.log`.
## 7. Debugging
True breakpoint debugging requires attaching Mono Soft Debugger to the
Unity Mono runtime that ships with Beat Saber. The procedure:
1. Add `--mono-debugger` to Beat Saber's launch arguments. Unity's Mono will
open a debugger socket on `127.0.0.1:55555` by default.
2. In Cursor, install **MonoDebug** (`MetinSeylan.mono-debug`) or use
`dnSpyEx`'s Mono debugger from a separate window.
3. The plugin must be built with `<DebugType>portable</DebugType>` (the
templates already do this for Debug).
In practice most mod authors lean on `Plugin.Log.Info(…)` + the BSIPA console
because it survives Proton's quirks better than the Mono debugger.
For inspecting the game's own assemblies, use [ILSpy](https://github.com/icsharpcode/ILSpy)
or the CLI:
```bash
dotnet tool install -g ilspycmd
ilspycmd "$HOME/.local/share/Steam/steamapps/common/Beat Saber/Beat Saber_Data/Managed/Main.dll" \
-o ~/decompiled/
```
The BSMG wiki has a dedicated [decompiling guide](../../../src/bsmg/wiki/wiki/modding/pc/decompiling.md).
## 8. Tips for LLM-assisted development
A few things make this codebase friendlier than average to LLM agents:
- **The reference graph is closed.** All Beat Saber types live under
`Beat Saber_Data/Managed/`. Have your agent run
`ilspycmd …/Managed/Main.dll -o decompiled/` once and grep the result when
it needs to know a method signature. Cache it in the repo (gitignored).
- **`manifest.json` is the source of truth for runtime dependencies**. When
your agent adds a `using BeatSaberMarkupLanguage…`, it must also add
`"BeatSaberMarkupLanguage": "^1.x"` to `dependsOn` and a corresponding
`<Reference>` in the `.csproj` — otherwise BSIPA will refuse to load the
plugin or the build will fail to find the type.
- **No nullable reference types in templates.** The BSMT templates are
pre-`<Nullable>enable</Nullable>`. Either keep that style or flip the
switch in `MyPlugin.csproj` and let the agent maintain `?`/`!` annotations
consistently.
- **Harmony patches are runtime-reflective.** Static analysis can't catch a
typo in a `[HarmonyPatch(typeof(Foo), nameof(Foo.Bar))]` attribute — add a
smoke test that asserts the patch attached at startup (BSIPA logs an error
if a patch fails to apply, so a log-line check works).
- **Game updates change DLL ABIs.** Pin a `gameVersion` and verify it on
startup; tell the agent to bail out loudly rather than silently wrap an
exception when the API drift is too large.
## 9. Repo layout suggestion
```
MyPlugin/
├─ .gitignore # ignore bin/, obj/, *.csproj.user, decompiled/
├─ Directory.Build.props # BSMT switches (ImportBSMTTargets, BSMTProjectType)
├─ MyPlugin.csproj # references + BeatSaberModdingTools.Tasks
├─ MyPlugin.csproj.user # local BeatSaberDir — gitignored
├─ manifest.json # BSIPA metadata (embedded into DLL at build time)
├─ Plugin.cs # BSIPA entry point
├─ MyPluginController.cs # MonoBehaviour singleton
├─ Configuration/
│ └─ PluginConfig.cs # BSIPA Generated config (uncomment to enable)
├─ Properties/
│ └─ AssemblyInfo.cs
└─ Refs/ # optional fallback for Beat Saber DLLs
# (LocalRefsDir wins over BeatSaberDir if present)
```
A starter `.gitignore`:
```gitignore
bin/
obj/
*.csproj.user
decompiled/
.vs/
.idea/
```
## References
- BSMG wiki — Linux modding: <https://bsmg.wiki/linux-modding.html>
- BSMG wiki — PC mod dev intro: [`setup.md`](../../../src/bsmg/wiki/wiki/modding/pc/setup.md)
- BSMG wiki — VS variant of the setup: [`vs-setup.md`](../../../src/bsmg/wiki/wiki/modding/pc/vs-setup.md)
- BSMG wiki — launch flags etc.: [`index.md`](../../../src/bsmg/wiki/wiki/modding/pc/index.md)
- BSIPA install reference: <https://nike4613.github.io/BeatSaber-IPA-Reloaded/articles/start-user.html>
- `BeatSaberModdingTools.Tasks` (the MSBuild engine that powers the templates):
<https://www.nuget.org/packages/BeatSaberModdingTools.Tasks>
- BSMT VS extension source (read-only reference for what the IDE plugin
automates): [`~/src/denpadokei/BeatSaberModdingTools`](../../../src/denpadokei/BeatSaberModdingTools)
- BSMT project templates (the ones we copy from): [`~/src/Zingabopp/UnityModdingTools.Templates.BeatSaber`](../../../src/Zingabopp/UnityModdingTools.Templates.BeatSaber)
- BSManager (recommended Linux installer): <https://github.com/Zagrios/bs-manager>
- BSIPA repo — Unity mod injector (reference): [`~/src/nike4613/BeatSaber-IPA-Reloaded`](../../../src/nike4613/BeatSaber-IPA-Reloaded)
- Song Core — plugin for handling custom song additions [`~/src/Kylemc1413/SongCore`](../../../src/Kylemc1413/SongCore)