setlist/docs/pc-modding.md

19 KiB

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, 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 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 and 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 lists three options; BSManager is what BSMG currently recommends.

# 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:

# 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:

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:

{
  // 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) Smallest possible BSIPA plugin — Plugin.cs + manifest.json.
BSIPA Plugin (Core) Adds a MonoBehaviour controller and a commented config example.
BSIPA Plugin (Disableable) Plugin that can be enabled/disabled at runtime.

The walkthrough below uses Core and a fictional plugin called MyPlugin.

5.1 Copy the template

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:

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):

<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.

{
  "$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

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

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):

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 or the CLI:

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.

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:

bin/
obj/
*.csproj.user
decompiled/
.vs/
.idea/

References