Compare commits

..

10 Commits

30 changed files with 5295 additions and 1 deletions
+429
View File
@@ -0,0 +1,429 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
*.env
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
[Dd]ebug/x64/
[Dd]ebugPublic/x64/
[Rr]elease/x64/
[Rr]eleases/x64/
bin/x64/
obj/x64/
[Dd]ebug/x86/
[Dd]ebugPublic/x86/
[Rr]elease/x86/
[Rr]eleases/x86/
bin/x86/
obj/x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
[Aa][Rr][Mm]64[Ee][Cc]/
bld/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Build results on 'Bin' directories
**/[Bb]in/*
# Uncomment if you have tasks that rely on *.refresh files to move binaries
# (https://github.com/github/gitignore/pull/3736)
#!**/[Bb]in/*.refresh
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.trx
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Approval Tests result files
*.received.*
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
.artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.idb
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
**/.paket/paket.exe
paket-files/
# FAKE - F# Make
**/.fake/
# CodeRush personal settings
**/.cr/personal
# Python Tools for Visual Studio (PTVS)
**/__pycache__/
*.pyc
# Cake - Uncomment if you are using it
#tools/**
#!tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
MSBuild_Logs/
# AWS SAM Build and Temporary Artifacts folder
.aws-sam
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
**/.mfractor/
# Local History for Visual Studio
**/.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
**/.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
+47
View File
@@ -0,0 +1,47 @@
# Agent notes — Beat Saber / this repo
## Repo
- Modding workflow and references live in `docs/pc-modding.md` (Linux + Cursor + `dotnet` CLI; no VS/Rider BSMT extension).
- The References section in `docs/pc-modding.md` (wiki paths, BSMT templates, BSIPA, SongCore, etc.) matches local git checkouts under `~/src/…` on this machine (same layout as the guides `../../../src/…` links from here). Read those directories first; only fetch upstream (raw GitHub, bsmg.wiki) if a checkout is missing.
- BSMG wiki (Modding section): Available on disk at `~/src/bsmg/wiki` (also opened via `bs-modding-tools.code-workspace`). Prefer that tree over web mirrors for static wiki content.
## Game install (BSManager)
- Path: `/home/pleb/.local/share/BSManager/BSInstances/1.40.8`
- Version pin: `1.40.8` (managed copy; launch modded build from BSManager, not Steams live folder).
- BSIPA: Present (`IPA/`, `IPA.exe`, `winhttp.dll`, `Plugins/`).
## Plugins currently in `Plugins/`
BeatSaverDownloader, BeatSaverUpdater, BSML, BS_Utils, PlaylistManager, SiraUtil, SongCore (+ manifests/libs as shipped).
## Host toolchain
- dotnet: `9.0.312` (SDK 6+ is fine for `net472` plugin builds per guide).
- ilspycmd: `9.1.0.0` (decompile/reference game or plugin assemblies from CLI).
- NuGet: Installed (per user setup).
## Conventions agents should respect
- Plugin projects are .NET Framework 4.7.2 class libraries loaded by BSIPA; builds are CIL — Linux `dotnet build` output is valid for the Proton game instance.
- Point `BeatSaberDir` / game references at the BSManager instance path above when editing project user files or HintPaths.
- **Single path, not compatibility layers:** Prefer one straightforward implementation. Avoid backwards-compatibility boilerplate (dual code paths, “try old API then new API” probes, feature flags for multiple stack versions) unless the user explicitly asked to support more than one game or dependency version.
- **Plugin version bump on compile:** Whenever you run `dotnet build` on `Setlist/` as part of agent work, increment the **patch** segment (the `z` in `0.0.z`) in both `Setlist/manifest.json` (`version`) and `Setlist/Properties/AssemblyInfo.cs` (`AssemblyVersion` / `AssemblyFileVersion` as `0.0.z.0`) **before** that build so IPAs `Setlist (Setlist): …` log line matches the new artifact. Skip bumping if you only build to reproduce a compile error without changing shipped bits (then fix and bump once before the successful build).
- **Build then validate:** Whenever agent work produces a **new shipped** `Setlist` build (version bump + successful `dotnet build` that outputs the plugin DLL), run the **Smoketest** below before considering the task finished, unless the user explicitly said not to launch the game.
## Smoketest (run game, check plugin log)
**Budget: the whole smoketest should finish in about 15 seconds wall time** (launch, log check, teardown). Do not use long `timeout` values such as 60s or 90s for routine validation. If Setlist lines reliably appear later than that, treat it as a plugin bug and fix startup work so the smoketest can pass — do not stretch the smoketest timeout to compensate.
Full env + exact `steam-run` / Proton / env vars: `docs/notes.md` under Testing.
Session / display: On Plasma + Wayland, the shell should have a sensible `DISPLAY` (often `:0`), `WAYLAND_DISPLAY` (e.g. `wayland-0`), and `XDG_RUNTIME_DIR` (e.g. `/run/user/$UID`). If they are empty, set them to match the logged-in desktop session before launching.
Launch (important): Wrap the `steam-run``proton``Beat Saber.exe``--no-yeet fpfc` invocation in **`timeout 15`** and run it in the **foreground** (stdin/stdout attached). In Cursors integrated terminal, the same command started with `&` in the background has been observed to exit immediately (only Proton `fsync` in capture, no new `_latest.log` lines). A short foreground `timeout` caps wall time and keeps the Proton/game tree alive long enough to boot.
1. `cd` to the BSManager instance path, export vars from `docs/notes.md` (and display vars above if needed).
2. Run e.g. `timeout 15 steam-run …/proton run …/Beat Saber.exe --no-yeet fpfc` (optionally `2>&1 | tee /tmp/bs-smoke.log`). Expect Setlist lines in `Logs/_latest.log` within **a few seconds** after a successful boot (well inside the 15s cap).
3. Confirm: `grep Setlist Logs/_latest.log` — expect current Setlist `INFO` lines (e.g. `platformUserId=…`, playlist lines with `hasSyncUrl=…`, `beatLeaderOwnerConfirmed=…`).
4. **When the smoketest has succeeded in the log** (expected Setlist lines present), **stop Beat Saber immediately** — do not wait for `timeout 15` to expire. Kill the game / Proton tree (e.g. `pkill -f "Beat Saber.exe"` or `pkill -f "Beat Saber"`, or `kill` the `steam-run` child tree if needed). If `timeout 15` already terminated everything, no extra kill is required.
5. If `_latest.log` does not show new Setlist lines within ~15s of a foreground launch, treat as failure: read `/tmp/bs-smoke.log` (if used) and the timestamped file under `Logs/`, or rerun from Konsole on the desktop if the IDE shell misbehaves. Kill any leftover `Beat Saber.exe` / Wine processes after a failed run too.
+253
View File
@@ -0,0 +1,253 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using BeatSaberPlaylistsLib.Types;
using UnityEngine;
using IPALogger = IPA.Logging.Logger;
namespace Setlist
{
/// <summary>
/// Parses BeatLeader-style sync URLs for logging. Ownership is determined in
/// <see cref="Plugin.FormatPlaylistLogLine"/> from playlist JSON only (no network).
/// </summary>
internal static class BeatLeaderPlaylistOwnership
{
private const float PlatformUserPollStepSeconds = 0.5f;
/// <summary>How long to wait for <see cref="PlatformLeaderboardsModel"/> to appear and populate <c>playerId</c> (plugin runs before menu init).</summary>
private const float PlatformUserWaitTimeoutSeconds = 30f;
private const float PlatformUserGetUserInfoRetrySeconds = 3f;
internal static bool TryExtractBeatLeaderPlaylistGuid(string syncUrl, out string guid)
{
guid = string.Empty;
if (string.IsNullOrWhiteSpace(syncUrl))
{
return false;
}
if (!Uri.TryCreate(syncUrl.Trim(), UriKind.Absolute, out var uri))
{
return false;
}
if (!string.Equals(uri.Host, "api.beatleader.com", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var segments = uri.AbsolutePath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
if (segments.Length != 3)
{
return false;
}
if (!string.Equals(segments[0], "playlist", StringComparison.OrdinalIgnoreCase)
|| !string.Equals(segments[1], "guid", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var id = segments[2];
if (id.Length != 32 || !id.All(Uri.IsHexDigit))
{
return false;
}
guid = id;
return true;
}
/// <summary>Reads sync URL, BeatLeader guid, and owner from playlist customData (same rules as startup scan).</summary>
internal static void TryReadBeatLeaderMetadata(
IPlaylist playlist,
out bool hasSyncUrl,
out string beatLeaderGuid,
out string ownerId)
{
hasSyncUrl = false;
beatLeaderGuid = null;
ownerId = null;
if (!playlist.TryGetCustomData("syncURL", out var syncObj) || !(syncObj is string syncUrl)
|| string.IsNullOrWhiteSpace(syncUrl))
{
return;
}
hasSyncUrl = true;
if (playlist.TryGetCustomData("owner", out var ownerObj) && ownerObj is string o
&& !string.IsNullOrWhiteSpace(o))
{
ownerId = o.Trim();
}
if (TryExtractBeatLeaderPlaylistGuid(syncUrl, out var g))
{
beatLeaderGuid = g;
}
}
/// <summary>
/// Spawns a hidden coroutine runner that resolves the platform user id, then logs per-playlist
/// ownership from playlist JSON. Must be called from the Unity main thread (BSIPA <c>OnApplicationStart</c> is fine).
/// </summary>
internal static void ScheduleVerifyAndLog(
List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> entries,
IPALogger log)
{
var go = new GameObject("Setlist.OwnershipRunner");
UnityEngine.Object.DontDestroyOnLoad(go);
go.hideFlags = HideFlags.HideAndDontSave;
var runner = go.AddComponent<OwnershipRunner>();
runner.Initialize(entries, log);
}
internal static IEnumerator FetchPlatformUserIdCoroutine(Action<string> onDone)
{
var waited = 0f;
var lastGetUserInfoAttempt = -PlatformUserGetUserInfoRetrySeconds;
while (waited < PlatformUserWaitTimeoutSeconds)
{
foreach (var plm in EnumerateLeaderboardModels())
{
var pid = plm.playerId;
if (!string.IsNullOrEmpty(pid))
{
onDone(pid.Trim());
yield break;
}
}
if (waited - lastGetUserInfoAttempt >= PlatformUserGetUserInfoRetrySeconds)
{
lastGetUserInfoAttempt = waited;
var model = ResolvePlatformUserModel();
if (model != null)
{
Task<UserInfo> task;
try
{
task = model.GetUserInfo(CancellationToken.None);
}
catch (Exception)
{
task = null;
}
if (task != null)
{
while (!task.IsCompleted)
{
yield return null;
}
if (!task.IsFaulted)
{
var uid = task.Result?.platformUserId;
if (!string.IsNullOrEmpty(uid))
{
onDone(uid.Trim());
yield break;
}
}
}
}
}
waited += PlatformUserPollStepSeconds;
yield return new WaitForSeconds(PlatformUserPollStepSeconds);
}
onDone(null);
}
/// <summary>
/// <see cref="Resources.FindObjectsOfTypeAll{T}"/> can miss very early; <see cref="Object.FindObjectOfType{T}()"/> sometimes finds the scene instance sooner.
/// </summary>
private static IEnumerable<PlatformLeaderboardsModel> EnumerateLeaderboardModels()
{
var seen = new HashSet<PlatformLeaderboardsModel>();
foreach (var plm in Resources.FindObjectsOfTypeAll<PlatformLeaderboardsModel>())
{
if (plm != null && seen.Add(plm))
{
yield return plm;
}
}
var active = UnityEngine.Object.FindObjectOfType<PlatformLeaderboardsModel>();
if (active != null && seen.Add(active))
{
yield return active;
}
}
private static IPlatformUserModel ResolvePlatformUserModel()
{
var field = typeof(PlatformLeaderboardsModel).GetField(
"_platformUserModel",
BindingFlags.Instance | BindingFlags.NonPublic);
if (field == null)
{
return null;
}
IPlatformUserModel last = null;
foreach (var plm in EnumerateLeaderboardModels())
{
if (field.GetValue(plm) is IPlatformUserModel m)
{
last = m;
}
}
return last;
}
/// <summary>
/// Component that drives the logging coroutine; self-destroys when finished.
/// </summary>
private sealed class OwnershipRunner : MonoBehaviour
{
private List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> _entries;
private IPALogger _log;
public void Initialize(
List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> entries,
IPALogger log)
{
_entries = entries;
_log = log;
StartCoroutine(Run());
}
private IEnumerator Run()
{
string platformUserId = null;
yield return StartCoroutine(FetchPlatformUserIdCoroutine(id => { platformUserId = id; }));
Plugin.CachedPlatformUserId = platformUserId;
_log.Info(string.IsNullOrEmpty(platformUserId)
? "platformUserId=(unknown)"
: $"platformUserId={platformUserId}");
foreach (var e in _entries)
{
_log.Info(Plugin.FormatPlaylistLogLine(
e.Title,
e.HasSyncUrl,
e.BeatLeaderGuid,
e.OwnerId,
platformUserId));
}
Destroy(gameObject);
}
}
}
}
+240
View File
@@ -0,0 +1,240 @@
using System;
using System.Collections;
using System.IO;
using System.Reflection;
using System.Text;
using BeatLeader.Utils;
using BeatSaberPlaylistsLib;
using BeatSaberPlaylistsLib.Types;
using UnityEngine;
using UnityEngine.Networking;
using IPALogger = IPA.Logging.Logger;
namespace Setlist
{
/// <summary>
/// POSTs an updated playlist to BeatLeader when the local copy is owned by the player (playlist JSON
/// <c>owner</c> vs platform user id). Uses Unity cookie session after BeatLeader sign-in.
/// </summary>
internal static class BeatLeaderPlaylistSync
{
private const string AuthenticationTypeName = "BeatLeader.API.Authentication";
private const string SignedInFieldName = "_signedIn";
private const float SignInWaitTimeoutSeconds = 90f;
private const int PostRequestTimeoutSeconds = 120;
/// <summary>POST <c>/user/playlist</c> with the serialized playlist body (same shape as the website sample).</summary>
internal static IEnumerator CoPostOwnedPlaylistToBeatLeader(IPlaylist playlist, IPALogger log)
{
BeatLeaderPlaylistOwnership.TryReadBeatLeaderMetadata(playlist, out var hasSyncUrl, out var beatLeaderGuid, out var ownerId);
var platformUserId = Plugin.CachedPlatformUserId;
if (string.IsNullOrEmpty(platformUserId))
{
string resolved = null;
yield return BeatLeaderPlaylistOwnership.FetchPlatformUserIdCoroutine(id => { resolved = id; });
platformUserId = resolved;
if (!string.IsNullOrEmpty(platformUserId))
{
Plugin.CachedPlatformUserId = platformUserId;
}
}
if (!IsLocallyOwnedBeatLeaderPlaylist(hasSyncUrl, beatLeaderGuid, ownerId, platformUserId))
{
yield break;
}
if (!TryGetBeatLeaderServerFields(playlist, out var serverPlaylistId, out var shared))
{
log.Info(
$"Setlist BeatLeader sync: skip \"{playlist.Title}\" (customData id missing; cannot POST).");
yield break;
}
var signedInField = TryResolveBeatLeaderSignedInField(log);
if (signedInField == null)
{
yield break;
}
var waited = 0f;
while (!IsSignedIn(signedInField))
{
if (waited >= SignInWaitTimeoutSeconds)
{
log.Info(
$"Setlist BeatLeader sync: login did not complete within {SignInWaitTimeoutSeconds:F0}s (playlist \"{playlist.Title}\").");
yield break;
}
yield return new WaitForSeconds(1f);
waited += 1f;
}
if (!TrySerializePlaylist(playlist, log, out var bodyJson))
{
yield break;
}
var url =
$"{BLConstants.BEATLEADER_API_URL}/user/playlist?id={Uri.EscapeDataString(serverPlaylistId)}&shared="
+ (shared ? "true" : "false");
byte[] bodyRaw = Encoding.UTF8.GetBytes(bodyJson);
using (var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST))
{
request.timeout = PostRequestTimeoutSeconds;
request.uploadHandler = new UploadHandlerRaw(bodyRaw);
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "text/plain;charset=UTF-8");
request.SetRequestHeader("User-Agent", "Setlist/" + GetAssemblyVersion());
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.ConnectionError)
{
log.Info("Setlist BeatLeader sync: network error: " + request.error);
yield break;
}
if (request.result == UnityWebRequest.Result.ProtocolError)
{
log.Info(
$"Setlist BeatLeader sync: HTTP {request.responseCode} playlist=\"{playlist.Title}\" body="
+ TruncateForLog(request.downloadHandler?.text));
yield break;
}
log.Info(
$"Setlist BeatLeader sync: OK HTTP {request.responseCode} playlist=\"{playlist.Title}\" body="
+ TruncateForLog(request.downloadHandler?.text));
}
}
private static bool IsLocallyOwnedBeatLeaderPlaylist(
bool hasSyncUrl,
string beatLeaderGuid,
string ownerId,
string platformUserId)
{
return hasSyncUrl
&& !string.IsNullOrEmpty(beatLeaderGuid)
&& !string.IsNullOrEmpty(ownerId)
&& !string.IsNullOrEmpty(platformUserId)
&& string.Equals(ownerId, platformUserId, StringComparison.Ordinal);
}
private static bool TryGetBeatLeaderServerFields(IPlaylist playlist, out string serverPlaylistId, out bool shared)
{
serverPlaylistId = null;
shared = false;
if (!playlist.TryGetCustomData("id", out var idObj) || idObj == null)
{
return false;
}
serverPlaylistId = Convert.ToString(idObj);
if (string.IsNullOrWhiteSpace(serverPlaylistId))
{
return false;
}
if (playlist.TryGetCustomData("shared", out var sharedObj) && sharedObj != null)
{
if (sharedObj is bool b)
{
shared = b;
}
else if (bool.TryParse(Convert.ToString(sharedObj), out var parsed))
{
shared = parsed;
}
}
return true;
}
private static bool TrySerializePlaylist(IPlaylist playlist, IPALogger log, out string json)
{
json = null;
var mgr = BeatSaberPlaylistsLib.PlaylistManager.DefaultManager;
IPlaylistHandler handler = null;
if (!string.IsNullOrEmpty(playlist.SuggestedExtension))
{
handler = mgr.GetHandlerForExtension(playlist.SuggestedExtension);
}
if (handler == null)
{
handler = mgr.GetHandlerForPlaylistType(playlist.GetType());
}
if (handler == null)
{
log.Info($"Setlist BeatLeader sync: no IPlaylistHandler for \"{playlist.Title}\".");
return false;
}
try
{
using (var stream = new MemoryStream())
{
handler.Serialize(playlist, stream);
json = Encoding.UTF8.GetString(stream.ToArray());
}
return !string.IsNullOrEmpty(json);
}
catch (Exception ex)
{
log.Info($"Setlist BeatLeader sync: serialize failed: {ex.Message}");
return false;
}
}
private static FieldInfo TryResolveBeatLeaderSignedInField(IPALogger log)
{
var beatLeaderAssembly = typeof(BLConstants).Assembly;
var authType = beatLeaderAssembly.GetType(AuthenticationTypeName);
if (authType == null)
{
log.Info($"Setlist BeatLeader sync: assembly has no {AuthenticationTypeName}.");
return null;
}
var field = authType.GetField(SignedInFieldName, BindingFlags.NonPublic | BindingFlags.Static);
if (field == null)
{
log.Info(
$"{AuthenticationTypeName} has no static field '{SignedInFieldName}' (BeatLeader API changed?).");
return null;
}
return field;
}
private static bool IsSignedIn(FieldInfo signedInField)
{
var value = signedInField.GetValue(null);
return value is bool b && b;
}
private static string GetAssemblyVersion()
{
var asm = typeof(BeatLeaderPlaylistSync).Assembly;
return asm.GetName().Version?.ToString() ?? "0.0.0";
}
private static string TruncateForLog(string s, int maxLen = 512)
{
if (string.IsNullOrEmpty(s))
{
return "";
}
return s.Length <= maxLen ? s : s.Substring(0, maxLen) + "…";
}
}
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Project properties consumed by BeatSaberModdingTools.Tasks (BSMT). -->
<Project>
<PropertyGroup>
<ImportBSMTTargets>True</ImportBSMTTargets>
<BSMTProjectType>BSIPA</BSMTProjectType>
</PropertyGroup>
</Project>
+135
View File
@@ -0,0 +1,135 @@
using System;
using System.Collections.Generic;
using BeatSaberPlaylistsLib.Types;
using IPA;
using PlaylistManager.Utilities;
using IPALogger = IPA.Logging.Logger;
namespace Setlist
{
[Plugin(RuntimeOptions.SingleStartInit)]
public class Plugin
{
internal static Plugin Instance { get; private set; }
/// <summary>
/// BSIPA logger (shows in BSIPA console / game logs when verbose).
/// </summary>
internal static IPALogger Log { get; private set; }
/// <summary>Set after the ownership scan resolves the platform user id; used when syncing after PlaylistManager adds a song.</summary>
internal static string CachedPlatformUserId { get; set; }
[Init]
public Plugin(IPALogger logger)
{
Instance = this;
Log = logger;
}
[OnStart]
public void OnApplicationStart()
{
try
{
SetlistSyncHost.Ensure();
Events.playlistSongAdded += OnPlaylistSongAdded;
var playlists = BeatSaberPlaylistsLib.PlaylistManager.DefaultManager.GetAllPlaylists(
includeChildren: true,
out AggregateException loadErrors);
if (loadErrors != null)
{
Log.Error(loadErrors.Message);
foreach (var inner in loadErrors.InnerExceptions)
{
Log.Error(inner.ToString());
}
}
if (playlists == null || playlists.Length == 0)
{
Log.Info("No playlists loaded (or playlist library not initialized yet).");
return;
}
var entries = new List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)>();
foreach (var playlist in playlists)
{
BeatLeaderPlaylistOwnership.TryReadBeatLeaderMetadata(
playlist,
out var hasSyncUrl,
out var blGuid,
out var ownerId);
entries.Add((playlist, playlist.Title, hasSyncUrl, blGuid, ownerId));
}
BeatLeaderPlaylistOwnership.ScheduleVerifyAndLog(entries, Log);
}
catch (Exception ex)
{
Log.Error(ex.ToString());
}
}
[OnExit]
public void OnApplicationQuit()
{
Events.playlistSongAdded -= OnPlaylistSongAdded;
}
private void OnPlaylistSongAdded(IPlaylistSong song, IPlaylist playlist)
{
SetlistSyncHost.Instance?.StartPostOwnedBeatLeaderPlaylist(playlist, Log);
}
/// <summary>
/// One-line log for a playlist. Ownership is inferred only from playlist JSON customData
/// (<c>owner</c> vs the game's platform user id). No network verification.
/// </summary>
internal static string FormatPlaylistLogLine(
string title,
bool hasSyncUrl,
string beatLeaderPlaylistGuid,
string ownerId,
string platformUserId)
{
var ownerToken = string.IsNullOrEmpty(ownerId) ? "owner=n/a" : $"owner={ownerId}";
string ownerMatchesPlatformPart;
if (!hasSyncUrl)
{
ownerMatchesPlatformPart = "ownerMatchesPlatform=n/a (no sync URL)";
}
else if (string.IsNullOrEmpty(beatLeaderPlaylistGuid))
{
ownerMatchesPlatformPart =
"ownerMatchesPlatform=n/a (sync URL is not an api.beatleader.com /playlist/guid/… URL)";
}
else if (string.IsNullOrEmpty(platformUserId))
{
ownerMatchesPlatformPart =
"ownerMatchesPlatform=unknown (platform user id not available yet; cannot compare to owner)";
}
else if (string.IsNullOrEmpty(ownerId))
{
ownerMatchesPlatformPart =
"ownerMatchesPlatform=false (BeatLeader playlist URL but no owner field in playlist JSON)";
}
else if (string.Equals(ownerId, platformUserId, StringComparison.Ordinal))
{
ownerMatchesPlatformPart = "ownerMatchesPlatform=true (playlist owner field equals platform user id)";
}
else
{
ownerMatchesPlatformPart =
"ownerMatchesPlatform=false (playlist owner field differs from platform user id)";
}
return $"Playlist \"{title}\": hasSyncUrl={hasSyncUrl}, {ownerToken}, {ownerMatchesPlatformPart}";
}
}
}
+15
View File
@@ -0,0 +1,15 @@
using System.Reflection;
using System.Runtime.InteropServices;
[assembly: AssemblyTitle("Setlist")]
[assembly: AssemblyDescription("Playlist sync plugin for Beat Saber")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("Setlist")]
[assembly: AssemblyCopyright("")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
[assembly: ComVisible(false)]
[assembly: Guid("50F53E6E-21D5-4780-8E67-273877DAA28C")]
[assembly: AssemblyVersion("0.1.0.0")]
[assembly: AssemblyFileVersion("0.1.0.0")]
+156
View File
@@ -0,0 +1,156 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProductVersion>8.0.30703</ProductVersion>
<SchemaVersion>2.0</SchemaVersion>
<ProjectGuid>{18417954-9A66-445B-A3E1-F1E4C216E79D}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>Setlist</RootNamespace>
<AssemblyName>Setlist</AssemblyName>
<!-- BSIPA plugins often target net472; this project uses net48. Todo: investigate why (SDK refs, BCL, host). -->
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<DebugSymbols>true</DebugSymbols>
<DebugType>portable</DebugType>
<LocalRefsDir Condition="Exists('..\Refs')">..\Refs</LocalRefsDir>
<BeatSaberDir>$(LocalRefsDir)</BeatSaberDir>
<AppOutputBase>$(MSBuildProjectDirectory)\</AppOutputBase>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition="$(DefineConstants.Contains('CIBuild')) OR '$(NCrunch)' == '1'">
<DisableCopyToPlugins>True</DisableCopyToPlugins>
</PropertyGroup>
<PropertyGroup Condition="'$(NCrunch)' == '1'">
<DisableCopyToPlugins>True</DisableCopyToPlugins>
<DisableZipRelease>True</DisableZipRelease>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="System.Data" />
<Reference Include="System.Xml" />
<Reference Include="System.Net.Http" />
<Reference Include="Main">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Main.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="DataModels">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\DataModels.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="PlatformUserModel">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\PlatformUserModel.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="HMLib">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\HMLib.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="HMUI">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\HMUI.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="IPA.Loader">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\IPA.Loader.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Unity.TextMeshPro">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Unity.TextMeshPro.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.UI">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UI.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.UIElementsModule">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UIElementsModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.UIModule">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UIModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.UnityWebRequestModule">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UnityWebRequestModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.VRModule">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.VRModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>$(BeatSaberDir)\Libs\Newtonsoft.Json.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="BeatSaberPlaylistsLib">
<HintPath>$(BeatSaberDir)\Libs\BeatSaberPlaylistsLib.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="BeatLeader">
<HintPath>$(BeatSaberDir)\Plugins\BeatLeader.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="PlaylistManager">
<HintPath>$(BeatSaberDir)\Plugins\PlaylistManager.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="BeatLeaderPlaylistOwnership.cs" />
<Compile Include="BeatLeaderPlaylistSync.cs" />
<Compile Include="Plugin.cs" />
<Compile Include="SetlistSyncHost.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="manifest.json" />
</ItemGroup>
<ItemGroup>
<None Include="Directory.Build.props" Condition="Exists('Directory.Build.props')" />
<None Include="Setlist.csproj.user" Condition="Exists('Setlist.csproj.user')" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BeatSaberModdingTools.Tasks">
<Version>2.0.0-beta7</Version>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies.net48">
<Version>1.0.3</Version>
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<!-- On Linux/macOS, BSMT's IsProcessRunning task is unsupported and treats Fallback=True as "game running",
so BSMT copies to IPA/Pending/Plugins. Mirror into the real Plugins folder for the usual layout. -->
<Target Name="CopyToPluginsOnUnixHost" AfterTargets="BSMT_CopyToPlugins" Condition="'$(OS)' == 'Unix' AND '$(DisableCopyToGame)' != 'True' AND '$(ContinuousIntegrationBuild)' != 'True' AND Exists('$(BeatSaberDir)\Plugins')">
<Copy SourceFiles="$(OutputPath)$(AssemblyName).dll" DestinationFiles="$(BeatSaberDir)\Plugins\$(AssemblyName).dll" SkipUnchangedFiles="true" />
<Copy SourceFiles="$(OutputPath)$(AssemblyName).pdb" DestinationFiles="$(BeatSaberDir)\Plugins\$(AssemblyName).pdb" SkipUnchangedFiles="true" Condition="Exists('$(OutputPath)$(AssemblyName).pdb')" />
</Target>
</Project>
+32
View File
@@ -0,0 +1,32 @@
using BeatSaberPlaylistsLib.Types;
using UnityEngine;
using IPALogger = IPA.Logging.Logger;
namespace Setlist
{
/// <summary>
/// Persists for <see cref="UnityEngine.MonoBehaviour.StartCoroutine"/> (e.g. BeatLeader POST after UI adds a song).
/// </summary>
internal sealed class SetlistSyncHost : MonoBehaviour
{
internal static SetlistSyncHost Instance { get; private set; }
internal static void Ensure()
{
if (Instance != null)
{
return;
}
var go = new GameObject("Setlist.SyncHost");
UnityEngine.Object.DontDestroyOnLoad(go);
go.hideFlags = HideFlags.HideAndDontSave;
Instance = go.AddComponent<SetlistSyncHost>();
}
internal void StartPostOwnedBeatLeaderPlaylist(IPlaylist playlist, IPALogger log)
{
StartCoroutine(BeatLeaderPlaylistSync.CoPostOwnedPlaylistToBeatLeader(playlist, log));
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://raw.githubusercontent.com/bsmg/BSIPA-MetadataFileSchema/master/Schema.json",
"id": "Setlist",
"name": "Setlist",
"author": "",
"version": "0.1.0",
"description": "Syncs playlists with external sources.",
"gameVersion": "1.40.8",
"dependsOn": {
"BSIPA": "^4.3.0",
"BeatSaberPlaylistsLib": "^1.7.0",
"BeatLeader": "^0.9.0",
"PlaylistManager": "^1.7.0"
}
}
+23 -1
View File
@@ -87,4 +87,26 @@ if (result.RequestState == WebRequests.RequestState.Finished && result.Result !=
Thats all thats required on the **client** side for “fetch my playlists”: **GET + existing cookie session**. No extra headers beyond what `WebRequestFactory` already applies (`User-Agent` + cookies).
**Troubleshooting:** right after implementing, compare behavior to `UserRequest` (`GET /user/modinterface`): if one returns `Finished` with data and the other gets `401`, the issue is session/host/HTTPS, not playlist-specific logic.
**Troubleshooting:** right after implementing, compare behavior to `UserRequest` (`GET /user/modinterface`): if one returns `Finished` with data and the other gets `401`, the issue is session/host/HTTPS, not playlist-specific logic.
## Field notes (Setlist / BS 1.40.8, 2026)
These observations are from running a **second plugin** (Setlist) alongside the **shipped** BeatLeader 0.9.x DLL on a **BSManager** 1.40.8 install. Treat them as the operational replacement for the `WebRequestFactory` recipe above when targeting that build.
- **Server:** `GET ~/user/playlists` returns **401** with no authenticated user in context. See `PlaylistController.GetAllPlaylists` in beatleader-server (`CurrentUserID` then `Unauthorized()`).
- **beatleader-mod _source_** uses `WebRequestFactory` (a static `HttpClient` over a `CookieContainer`). That is what the main sections of this file describe.
- **Shipped `BeatLeader.dll` (0.9.x in 1.40.8) is _older_ than that source** and the layout is different. Verified with `ilspycmd -l class`:
- `BeatLeader.WebRequests.WebRequestFactory` does **not** exist.
- The networking stack is `BeatLeader.API.NetworkingUtils` + `BeatLeader.API.RequestDescriptors.JsonGetRequestDescriptor<T>` + `BeatLeader.API.RequestHandlers.PersistentSingletonRequestHandler<T,R>` + `BeatLeader.API.Methods.*` (e.g. `UserRequest`, `PlaylistRequest`).
- Every call funnels through `UnityWebRequest` (`UnityWebRequest.Get(url)` from `JsonGetRequestDescriptor.CreateWebRequest`).
- Sign-in is `BeatLeader.API.Authentication`: a coroutine `EnsureLoggedIn(Action onSuccess, Action<string> onFail)` that posts `BLConstants.SIGNIN_WITH_TICKET` via `UnityWebRequest.Post`. `ResetLogin()` clears with `UnityWebRequest.ClearCookieCache(...)` — confirming the session cookie lives in **Unity's cookie cache**, not in any `HttpClient`/`CookieContainer`.
- **Cookie sharing on this build _does_ work via `UnityWebRequest`.** Unity's `UnityWebRequest` cookie cache is **process-wide per host**, so a second plugin issuing `UnityWebRequest.Get("https://api.beatleader.com/user/playlists")` after BeatLeader's sign-in inherits the ASP.NET cookie automatically. (The earlier "UnityWebRequest tends to 401" observation was from racing BeatLeader's login — not from a separate cookie store.)
- **Detecting "BeatLeader is signed in" without a public hook on this build:** `BeatLeader.API.Authentication` is `internal` and its successful-login state is the private static field `_signedIn`. Reflect into `typeof(BLConstants).Assembly.GetType("BeatLeader.API.Authentication").GetField("_signedIn", BindingFlags.NonPublic | BindingFlags.Static)` and poll until `true`, then issue the `UnityWebRequest`. (Triggering `EnsureLoggedIn` ourselves is brittle: it depends on `Resources.FindObjectsOfTypeAll<PlatformLeaderboardsModel>()` having returned by the time we call it — easier to wait for BeatLeader's own login coroutine to finish.)
- **Concrete recipe used by Setlist (`Setlist/BeatLeaderPlaylistOwnership.cs`):**
1. Spawn a hidden `MonoBehaviour` via `new GameObject(...).AddComponent<>()` from `OnApplicationStart` (BSIPA's `[OnStart]` runs on the Unity main thread).
2. In a coroutine, poll `Authentication._signedIn` (with a generous timeout, e.g. 90 s; sign-in only happens once the menu scene loads and platform tickets resolve).
3. `UnityWebRequest.Get(BLConstants.BEATLEADER_API_URL + "/user/playlists")`, set a `User-Agent`, `yield return SendWebRequest()`.
4. Check `request.result == UnityWebRequest.Result.Success`, then `JsonConvert.DeserializeObject<List<UserPlaylistSummary>>(request.downloadHandler.text)`.
5. Build a `HashSet<string>` of `guid` values; per-playlist ownership is just `set.Contains(extractedGuid)`.
- **JSON shape** matches the model in §1: camelCase, only `guid` is required for ownership; `id`, `ownerId`, `isShared`, `link`, `hash`, `deleted` are optional metadata.
- **Reference reading on disk:** `~/src/beatleader/beatleader-mod/Source/2_Core/API/{WebRequestFactory,Authentication,PersistentWebRequest}.cs` (newer source); `ilspycmd -p -o /tmp/bl-dec /home/pleb/.local/share/BSManager/BSInstances/1.40.8/Plugins/BeatLeader.dll` to inspect the **shipped** types (`BeatLeader.API.{Authentication,NetworkingUtils}` + `RequestHandlers.PersistentSingletonRequestHandler`).
+95
View File
@@ -0,0 +1,95 @@
# Bootstrapping the Setlist BSIPA plugin
This documents how the `Setlist/` project was created (aligned with `docs/pc-modding.md` §5). Official references: [BSMT templates repo](https://github.com/Zingabopp/UnityModdingTools.Templates.BeatSaber), [BeatSaberModdingTools.Tasks on NuGet](https://www.nuget.org/packages/BeatSaberModdingTools.Tasks), [BSIPA user install](https://nike4613.github.io/BeatSaber-IPA-Reloaded/articles/start-user.html).
## 1. Choose template
We used the **BSIPA Plugin (Bare)** template (smallest layout: `Plugin.cs`, `manifest.json`, `AssemblyInfo`, MSBuild wiring). The upstream folder is `BSIPA Plugin (Bare)/` in the templates repository.
The guides relative paths (e.g. `~/src/Zingabopp/UnityModdingTools.Templates.BeatSaber`) assume a local clone; this machine had no clone, so files were taken from raw GitHub with `curl` (same content as a copy from a clone).
## 2. Project layout
Created under repo root:
| File | Role |
|------|------|
| `Setlist.csproj` | `net472` library, game assembly references via `$(BeatSaberDir)`, `BeatSaberModdingTools.Tasks` package |
| `Directory.Build.props` | `ImportBSMTTargets` + `BSMTProjectType` = BSIPA (from `Directory.Build.props.template`) |
| `Setlist.csproj.user` | **Local only** (gitignored via `*.user`): `BeatSaberDir` → BSManager managed instance |
| `manifest.json` | BSIPA metadata; embedded at build |
| `Plugin.cs` | `[Plugin]` entry; `Log.Info("Hello World")` in `[OnStart]` |
| `Properties/AssemblyInfo.cs` | Assembly identity / version |
## 3. Placeholders
Bare template uses `$safeprojectname$`, `$guid1$`, `$targetframeworkversion$`, etc. These were expanded by hand to:
- `Setlist` (assembly / namespace / plugin id)
- A new `ProjectGuid` and `[assembly: Guid(...)]`
- `TargetFrameworkVersion``4.7.2`
## 4. `manifest.json`
- **id / name:** `Setlist`
- **description:** Short line describing playlist sync intent
- **gameVersion:** `1.40.8` — taken from the installs `BeatSaberVersion.txt` (use the `major.minor.patch` prefix; the file may include a `_build` suffix)
- **dependsOn.BSIPA:** `^4.3.0` (per guide; adjust if your BSIPA major differs)
## 5. NuGet packages (`dotnet restore`)
No separate `nuget install` was required; `dotnet restore` / `dotnet build` resolved everything from nuget.org.
| Package | Purpose |
|---------|---------|
| `BeatSaberModdingTools.Tasks` | BSMT MSBuild targets: manifest embedding, copy to game, release zip, etc. Template used `2.0.0-beta1`; we pinned **`2.0.0-beta7`** (latest beta on NuGet at bootstrap time). |
| `Microsoft.NETFramework.ReferenceAssemblies.net472` **`1.0.3`** | **Required on this Linux host** (Nix-provided .NET SDK): first build failed with **MSB3644** (missing .NET Framework 4.7.2 reference assemblies). Adding this package fixes that without a Windows targeting pack. The guide notes BSMT may pull reference assemblies transitively; that did not satisfy MSBuild here, so the package was added **explicitly** to `Setlist.csproj`. |
## 6. `BeatSaberDir` / `GameDirectory`
`Setlist.csproj.user` sets:
```xml
<BeatSaberDir>/home/pleb/.local/share/BSManager/BSInstances/1.40.8</BeatSaberDir>
```
BSMT resolves the game root as `GameDirectory` and expects `Beat Saber.exe` (or `Beat Saber`) there. Point at the **BSManager managed copy**, not the Steam tree, if that is what you mod.
## 7. Build
```bash
cd Setlist
dotnet restore
dotnet build -c Debug
```
Expect:
- `bin/Debug/Setlist.dll` (+ `.pdb`)
- BSMT artifact layout under `bin/Debug/Artifact/Plugins/`
## 8. Where the DLL lands (Linux caveat)
`BeatSaberModdingTools.Tasks` runs an `IsProcessRunning` check before copying. **On Unix the task is unsupported**; with `Fallback="True"` BSMT behaves as if the game were running and copies to:
`$GameDirectory/IPA/Pending/Plugins/`
So you may see **`IPA/Pending/Plugins/Setlist.dll`** even when Beat Saber is not running.
This repo adds a small **`CopyToPluginsOnUnixHost`** target at the end of `Setlist.csproj` that, on `$(OS) == 'Unix'`, also copies `$(OutputPath)Setlist.dll` (and `.pdb`) into **`$(BeatSaberDir)/Plugins/`**, so a normal Debug build matches the layout the game loads from **`Plugins/`**.
After a successful `dotnet build -c Debug`, verify:
```bash
test -f "$BEAT_SABER_DIR/Plugins/Setlist.dll" && echo OK
```
(Replace `$BEAT_SABER_DIR` with your `BeatSaberDir`.)
## 9. Seeing “Hello World”
BSIPA logging uses `IPA.Logging.Logger` (exposed as `Log` in `Plugin.cs`). With **`--verbose`** (and optionally **`--debug`** for Unity logs), start the game from BSManager and check the BSIPA console or logs under the game folder (see guide §6).
## 10. CI note
Pass **`-p:ContinuousIntegrationBuild=true`** (or define `CIBuild` per template) to disable copying into the game directory on build agents.
+30
View File
@@ -0,0 +1,30 @@
---
prev: false
next: false
description: Learn how to create your own mods!
---
# Making Mods
Beat Saber _**does not**_ have built in mod support.
Development for [PC](#pc-mod-development) and [Quest standalone](#quest-mod-development) are two vastly different workflows.
## PC Mod Development
If you want to make mods for the PC version of the game, the following guide will cover a multitude of different
processes involved in making mods from scratch, as well as some of the different APIs you have access to.
Visit the [PC Mod Development](./pc/index.md) page to begin.
## Quest Mod Development
The following guide covers most of the concepts you will need for creating mods for the Quest. This includes but is not
limited to:
- Hooking
- Configuration using `config-utils`
- User Interfaces using `bsml`
- Custom types
Visit the [Quest Mod Development Intro](./quest/intro.md) page for more information on getting started!
+362
View File
@@ -0,0 +1,362 @@
---
prev: Harmony Patching
next: Zenject and SiraUtil
---
# Creating Beat Saber UI
[BeatSaberMarkupLanguage (BSML)](https://github.com/monkeymanboy/BeatSaberMarkupLanguage) is the most common way to
create customized UI in Beat Saber. BSML is effectively a tag-based language that mimics the GameObject hierarchy
of Unity. It parses tags into GameObjects, and attaches the relevant Unity and Beat Saber UI elements to them.
The documentation for all BSML components can be found [here](https://monkeymanboy.github.io/BSML-Docs/).
## Getting Set Up
Of course, if you want to add BSML in your mod, make sure that you have it installed in your game, and your project
is referencing BSML.
### Creating the BSML file
You can name the file anything you want, just make sure that its file extension is `.bsml`.
![BSML File Screenshot](/.assets/images/modding/pc-mod-bsml-file.jpg 'BSML File Screenshot')
BSML will require that the bsml file be embedded in the assembly. You can do this by right-clicking the file in the
explorer, going to properties, and then changing the build action to `EmbeddedResource`.
![Embedded Resource Property Screenshot](/.assets/images/modding/pc-mod-bsml-embeddedresource.jpg 'Embedded Resource Property Screenshot')
### Writing in BSML
If you're using Rider, you may have to add a file association for `.bsml` files to get basic syntax highlighting. To
do this, go to `File | Settings | Editor | File Types` and search for `XML`. Add a new file name pattern as `*.bsml`.
This will make Rider accept `.bsml` files as XML files and do highlighting accordingly.
To get autocompletion in a BSML file, you will need to provide a schema. A way to do this is to use the
[background tag](https://monkeymanboy.github.io/BSML-Docs/Tags/BackgroundTag/) and add the schema to it:
```xml
<bg xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
xsi:noNamespaceSchemaLocation='https://monkeymanboy.github.io/BSML-Docs/BSMLSchema.xsd'>
</bg>
```
Rider may prompt you that the resource is not found. Simply right click on the URL, or press `Alt+Enter`, and select
fetch external resource.
Once set up, you should have basic autocompletion for tags if you start typing inside the `<bg>` tag.
## Running Code In The Menu
There are a couple different ways you can display your BSML in game, however, it is first important to note that
you should not call any of the methods mentioned below outside of the main menu. You should make sure the game has finished
loading the main menu before doing anything.
- The recommended method, if you don't already use SiraUtil, is BSML's own `MainMenuAwaiter` class that has an
[event](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/events/) called `MainMenuInitializing`
that is invoked when the main menu loads
- If you are using SiraUtil, it is recommended to bind a type with a `Location.Menu`, or on the `MainSettingsMenuViewControllersInstaller`
- BS Utils also provides events in `BSEvents` and they are called `earlyMenuSceneLoadedFresh` and `lateMenuSceneLoadedFresh`
- You can use the game's `GameScenesManager` and the `transitionDidFinishEvent`, then check if the output `ScenesTransitionSetupDataSO`
is a `MenuScenesTransitionSetupDataSO` using a
[type test expression](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/type-testing-and-cast)
- If you want to get an event when the main menu loads yourself, you can use Unity's
[SceneManager](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/SceneManagement.SceneManager.html)
and check the name of the loaded scene manually, however this is a lot more effort than all of the other methods
- You could also use a Harmony patch into a method that will run every time the menu reinitializes, but this is also unnecessarily
complicated
## Adding Menus
Once you have code running in the main menu, it's time to decide where you want to display your UI.
### Mod Settings
The mod settings menu is added by BSML and can be accessed from a custom button in the main menu settings. To register
your own tab, check the `BSMLSettings` class. `TutorialMenu` is just a normal class.
```c#
private readonly TutorialMenu tutorialMenu = new TutorialMenu();
public void AddSettingsMenu()
{
BSMLSettings.Instance.AddSettingsMenu(
name: "Tutorial Mod",
resource: "TutorialMod.tutorial.bsml",
host: tutorialMenu);
}
```
![Mod Settings Screenshot](/.assets/images/modding/pc-mod-bsml-settings.jpg 'Mod Settings Screenshot')
### Gameplay Setup
The mods tab is added by BSML in the Gameplay Setup menu, which is found to the left of the song list, where
you can normally find player settings and gameplay modifiers. To register a new tab, check the `GameplaySetup` class.
`TutorialMenu` is just a normal class.
```c#
private readonly TutorialMenu tutorialMenu = new TutorialMenu();
public void AddTab()
{
GameplaySetup.Instance.AddTab(
name: "Tutorial Mod",
resource: "TutorialMod.tutorial.bsml",
host: tutorialMenu);
}
```
![Mod Tabs Screenshot](/.assets/images/modding/pc-mod-bsml-tabs.jpg 'Mod Tabs Screenshot')
### Custom Flow Coordinator
BSML gives you a way to create a button in the left screen of the main menu. This button can do anything you want it
to do, but most modders make it present their mod's UI. This is done by using a `FlowCoordinator`, and by adding one
or more `ViewController` objects to it.
BSML provides methods to create both flow coordinators and view controllers, which makes this process a lot cleaner.
BSML has a few choices of view controller types you can inherit; we are going to use the `BSMLAutomaticViewController`
because it has the option of hot reloading the menu when you make changes to the bsml file.
```c#
[ViewDefinition("TutorialMod.tutorial.bsml")]
public class TutorialViewController : BSMLAutomaticViewController { }
```
The flow coordinator is responsible for managing view controllers. `FlowCoordinator` has many members that you can use
or override, so it's worth checking out the code for it.
```c#
public class TutorialFlowCoordinator : FlowCoordinator
{
private readonly TutorialViewController tutorialViewController = BeatSaberUI.CreateViewController<TutorialViewController>();
// Called immediately when the flow coordinator is activated
protected override void DidActivate(bool firstActivation, bool addedToHierarchy, bool screenSystemEnabling)
{
if (firstActivation)
{
// Sets the title text in the top bar
SetTitle("Tutorial Mod");
showBackButton = true;
}
if (addedToHierarchy)
{
ProvideInitialViewControllers(tutorialViewController);
}
}
protected override void BackButtonWasPressed(ViewController topViewController)
{
BeatSaberUI.MainFlowCoordinator.DismissFlowCoordinator(this);
}
}
```
The code below is related to managing the menu button. We tell the `MainFlowCoordinator` to present our own
flow coordinator. You can also have your own way to dismiss your flow coordinator but, in the example above,
we are relying on the back button to do this.
```c#
private readonly TutorialFlowCoordinator tutorialFlowCoordinator;
private readonly MenuButton menuButton;
public MenuManager()
{
tutorialFlowCoordinator = BeatSaberUI.CreateFlowCoordinator<TutorialFlowCoordinator>();
menuButton = new MenuButton("Tutorial Mod", ShowFlowCoordinator);
}
public void AddMenuButton()
{
MenuButtons.Instance.RegisterButton(menuButton);
}
private void ShowFlowCoordinator()
{
BeatSaberUI.MainFlowCoordinator.PresentFlowCoordinator(tutorialFlowCoordinator);
}
```
![Mod Buttons Screenshot](/.assets/images/modding/pc-mod-bsml-buttons.jpg 'Mod Buttons Screenshot')
### Floating Screen
If you want to place your UI components anywhere, you can create a floating screen. This will allow you to have a view controller
anywhere in the world. You can also create a handle for the floating screen which will allow the player to move the screen
around.
The example below creates just creates a small screen near the ground in front of the player's place.
```c#
private readonly TutorialViewController tutorialViewController = BeatSaberUI.CreateViewController<TutorialViewController>();
public void CreateFloatingScreen()
{
var floatingScreen = FloatingScreen.CreateFloatingScreen(
screenSize: new Vector2(25f, 10f),
createHandle: false,
position: new Vector3(0f, 0.5f, 2f),
rotation: Quaternion.Euler(45f, 0f, 0f));
floatingScreen.SetRootViewController(tutorialViewController, ViewController.AnimationType.None);
}
```
Since floating screens aren't part of the screen system, and because the menu persists during gameplay, you can have
the floating screen active in the game scene. The below screenshot is of the floating screen from
[SliceDetails](https://github.com/qqrz997/SliceDetails), which is activated when the game is paused.
![Floating Screen Screenshot](/.assets/images/modding/pc-mod-bsml-floating-screen.jpg 'Floating Screen Screenshot')
## Interacting With The Menu
Now let's take a look at some of the ways you can make use of your UI. Again, to find out more about the components
that we will talk about in the following sections, check the [BSML documentation](https://monkeymanboy.github.io/BSML-Docs/).
### Buttons And Actions
We are going to add a [button](https://monkeymanboy.github.io/BSML-Docs/Tags/ButtonTag/) to the menu:
```xml
<button on-click="ButtonClicked" text="A Button"/>
```
And add the corresponding method in the object host or view controller:
```c#
public void ButtonClicked() => Plugin.Log.Info("Button Clicked");
```
Now, `ButtonClicked()` will get called whenever our button is clicked.
If you want to run a different method or a method with a different name to the one specified, you can use the
[UIAction](https://monkeymanboy.github.io/BSML-Docs/Attributes/UIAction/) annotation and specify the name:
```c#
[UIAction("ButtonClicked")]
public void SomeMethodName() { }
```
### UI Components
BSML components must be part of and accessed from the provided host object or view controller. To access the instance of
a BSML component, you must give one an `id`:
```xml
<text id="textComponent" text="Hello World!" align="Center"/>
```
And then add it in the object host by adding a [UIComponent](https://monkeymanboy.github.io/BSML-Docs/Attributes/UIComponent/)
annotation:
```c#
[UIComponent("textComponent")]
private readonly TextMeshProUGUI textComponent = null!; // assigned by BSML
```
If you want to have initialization logic for components in your UI, do not use Unity's `Awake()` or `Start()` or a constructor,
instead use the post-parse event provided by BSML. This will be called after all of the UI has been created and all components
on the object host have been assigned a value.
```c#
[UIAction("#post-parse")]
public void PostParse()
{
textComponent.text = "The text has changed.";
}
```
### Settings And Values
There are many different ways to get input values from BSML. Let's take a look at the
[toggle](https://monkeymanboy.github.io/BSML-Docs/Tags/ToggleSettingTag/) and
[slider](https://monkeymanboy.github.io/BSML-Docs/Tags/SliderSettingTag/) settings:
```xml
<vertical child-expand-height="false">
<toggle-setting value="ToggleValue" text="Toggle Example" apply-on-change="true"/>
<slider-setting value="SliderValue" text="Slider Example" apply-on-change="true"/>
</vertical>
```
We use `apply-on-change` to make the property get set when the input value changes, otherwise you would need to use
[INotifyPropertyChanged](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.inotifypropertychanged)
when you want to apply the values, which can still be useful if you want to manually do it.
```c#
private bool toggleValue;
private float sliderValue;
public bool ToggleValue
{
get => toggleValue;
set
{
toggleValue = value;
Plugin.Log.Info($"Toggle set to {value}");
}
}
public float SliderValue
{
get => sliderValue;
set
{
sliderValue = value;
Plugin.Log.Info($"Slider set to {value}");
}
}
```
If you want a property with a different name to the one specified, you can use the [UIValue](https://monkeymanboy.github.io/BSML-Docs/Attributes/UIValue/)
annotation and specify the name:
```c#
[UIValue("ToggleValue")]
public bool SomePropertyName { get; set; }
```
### Displaying Data
As well as taking input in your UI, it's very common to need to display data. Let's add a [list](https://monkeymanboy.github.io/BSML-Docs/Tags/ListTag/):
```xml
<list data="ListData"/>
```
And set the data through a property:
```c#
private IList<CustomListTableData.CustomCellInfo> ListData =>
[
new("A list cell", "and"),
new("Another list cell", "and"),
new("Another list cell", "that is all.")
];
```
Or alternatively, you can grab the
[CustomListTableData](https://monkeymanboy.github.io/BSML-Docs/TypeHandlers/CustomListTableData/)
component from the list by adding an `id` and use that:
```c#
[UIComponent("List")]
private readonly CustomListTableData list = null!; // assigned by BSML
[UIAction("#post-parse")]
public void PostParse()
{
list.Data = [
new("A list cell", "and"),
new("Another list cell", "and"),
new("Another list cell", "that is all.")
];
list.TableView.ReloadData();
}
```
+72
View File
@@ -0,0 +1,72 @@
---
prev: Runtime Unity Editor
next: Harmony Patching
---
# Decompiling
When modding Beat Saber and patching the game to change certain behaviour, it's important to be able to read
the game's code itself. There are some tools to help with this.
## Tools
Rider and Visual Studio do have built-in decompilers to let you see under the hood of types.
![Rider Decompiling](/.assets/images/modding/pc-mod-rider-decompiling.jpg 'Rider Decompiling')
This will only have limited usage and won't help you browse the types the game has to offer, or see how different
parts of the game's code interact.
### ILSpy
[ILSpy](https://github.com/icsharpcode/ILSpy) is a lightweight decompiler for C# dlls which will allow you to freely
browse the different types, variables, and methods that are contained within the game's own dlls. Grab the installer
from the [releases](https://github.com/icsharpcode/ILSpy/releases) and install ILSpy.
Once you have ILSpy opened, find the `Manage Assembly Lists` icon in the top bar and create a new list. You can name it
after the Beat Saber version you are working on. Once created, double click it to open the list.
![ILSpy List Screenshot](/.assets/images/modding/pc-mod-ilspy-list.jpg 'ILSpy List Screenshot')
To add binaries, click the `Open` icon in the top bar and navigate to your game folder. You are looking for
`/Beat Saber_Data/Managed`, select everything in this folder and open them into ILSpy. This will also include the
.NET framework and Unity assemblies, so that when you are looking at types from Beat Saber, all of the references will
be resolved.
### dnSpy
[dnSpy](https://github.com/dnSpyEx/dnSpy) is a much more in-depth tool for developing .NET programs; it has a
debugger, assembly editor, and more. It also has a decompiler built in to it for browsing decompiled C#, just like
ILSpy.
You can get dnSpy from the [releases](https://github.com/dnSpyEx/dnSpy/releases) on GitHub. Extract the zip archive and
run the .exe to get started. Similarly to ILSpy, you create a new list by going to `File`, then `Open List...`, and
adding a new list. You can name it after the Beat Saber version you are working on. Once created, double click it to
open the list.
Click the `Open` icon in the top bar or press `Ctrl+O` and navigate to `Beat Saber/Beat Saber_Data/Managed`,
select everything in this folder and open them into your list. To start searching, click the `Search Assemblies` in the
top bar.
## Browsing the Code
Beat Saber is a complex game with a lot of different assemblies, but it is pretty well organized and you can expect to
find what you are looking for where it should be. Something that may help is to find an object in game using RUE,
and by checking the MonoBehaviours attached to them, you can search for them in ILSpy.
![ILSpy Search Screenshot](/.assets/images/modding/pc-mod-ilspy-search.jpg 'ILSpy Search Screenshot')
If you double click a type in the search window, or in the assembly list, you will see the decompiler's interpretation
of that type and the corresponding C# code.
![ILSpy Code Screenshot](/.assets/images/modding/pc-mod-ilspy-code.jpg 'ILSpy Code Screenshot')
An important trick to know is analyzing members of a type. By pressing `Ctrl+R` or right-clicking and `Analyze` on,
for example, a public method, you will see the usages of that member. In the example below, the method
`FlyingScoreEffect.InitAndPresent` is called by `FlyingScoreSpawner.SpawnFlyingScore`.
![ILSpy Analyze Screenshot](/.assets/images/modding/pc-mod-ilspy-analyze.jpg 'ILSpy Analyze Screenshot')
This tool will be very important when writing [Harmony patches](./harmony-patching.md), which will be covered in the next
section of this wiki. You will want to be able to know how different parts of the code interact so that you can work out
where you should implement custom behaviour in your mod.
+627
View File
@@ -0,0 +1,627 @@
---
prev: Zenject and SiraUtil
next: false
---
# Full Mod Guide
This part of the wiki will be dedicated to showing the full process of making a Beat Saber mod.
## The Mod
The first step of creating a mod is understanding exactly what you want to achieve.
In this tutorial, we will be creating a mod capable of changing the "MISS" effect and replacing it with text. The mod
will have an in-game interface to allow you to change the text through a text input. The mod will be designed in a
decoupled way, which will make it easier to add new features to the mod later if we wish.
We can use [BSML](./bsml.md) for the UI, and we can use [SiraUtil](./zenject.md) to create our custom text effects
while remaining loosely coupled to in-game functions.
### Creating The Project
The first thing we are going to do is set up the plugin template. Refer to the [setup guide](./setup.md) for more information.
We will name the plugin `MissTextChanger` and add dependencies to `BSML` and `SiraUtil` in the metadata.
This will start from a bare-bones BSIPA template, going step by step through the testing process of making a simple
plugin to help people understand everything. If you're following along, you can also just use the full template, which
has a basic SiraUtil and BSML setup already done.
### Figuring Out The Game
Before going any further, we need to get an understanding of how the game handles miss text normally. First, let's go in
to [ILSpy](./decompiling.md), and search for "ScoreController". This class is responsible for basically everything related
to giving the player score, so we can figure out how misses are handled from here.
In the `Start()` method of the `ScoreController`, we can see the `noteWasMissedEvent` being assigned to which is a part
of the `BeatmapObjectManager`. Let's analyze this event and see what the `add` method of the event is used by. We can now
see the `MissedNoteEffectSpawner` which, as we can assume by its name, is exactly what we're looking for.
![Analyzing BeatmapObjectManager Event](/.assets/images/modding/pc-mod-tutorial-event-analyze.jpg 'Analyzing BeatmapObjectManager Event')
Looking into the `MissedNoteEffectSpawner` we can see all it is doing is taking data from the missed note's `NoteController`
and passing it to a `FlyingSpriteSpawner` to spawn the effect. The sprite spawner manages a
[Zenject Pool](https://github.com/Mathijs-Bakker/Extenject/blob/master/Documentation/MemoryPools.md)
of sprite effects.
If we analyze the `FlyingSpriteEffect.Pool` we can figure out where it is bound by checking where it is used.
![Analyzing FlyingSpriteEffect Pool](/.assets/images/modding/pc-mod-tutorial-pool-analyze.jpg 'Analyzing FlyingSpriteEffect Pool')
Now, looking at the `EffectPoolsManualInstaller.ManualInstallBindings()` method we see a couple different memory pools here.
One that is particularly interesting is the `FlyingTextEffect`, which if we analyze we can see the `FlyingTextSpawner`.
This is surely something we can use to achieve customizable miss text, however, looking at and comparing the spawn
methods for the sprite and text spawners, they are not exactly the same. The `x` of the `targetPos` vector is anchored
in the sprite spawner by its sign, which is why we see miss effects only fly to two locations to the left and right of
the track; there are only two possible values for sign.
Because of this difference, if we wanted to maintain the same visuals, we cannot use the `FlyingTextSpawner` for our needs.
We could use a harmony patch to change how the `SpawnFlyingSprite()` method works, but this may affect other mods that may
want to use this.
### The Solution
Instead of using the game's methods for our needs, let's make a custom effect spawner, and a custom flying object
effect. This should ensure that our mod doesn't conflict with other mods' features, but we're going to have to patch in to
the `MissedNoteEffectSpawner` to replace the base-game's miss effect with our custom one.
Let's start with the `MissTextEffect`, which will inherit `FlyingObjectEffect` like the other effects. For the text, we
will want a `TextMeshPro`.
```c#
internal class MissTextEffect : FlyingObjectEffect
{
// This is the pool from Zenject
public class Pool : MonoMemoryPool<MissTextEffect>;
// We don't have something to use here yet, we will get one later
private AnimationCurve fadeAnimationCurve;
// This field is serialized so that it will be included on instantiation
[SerializeField]
public TextMeshPro? textMesh;
private Color color;
public void InitAndPresent(string text, float duration, Vector3 targetPos,
Quaternion rotation, Color color, float fontSize, bool shake)
{
if (textMesh == null) return;
this.color = color;
textMesh.text = text;
textMesh.fontSize = fontSize;
InitAndPresent(duration, targetPos, rotation, shake);
}
public override void ManualUpdate(float t)
{
if (textMesh != null)
textMesh.color = color with { a = fadeAnimationCurve.Evaluate(t) };
}
}
```
We have some things to fill in on this object, but we will figure that out a bit later.
Next let's look at the spawner. We will be making sure to match the logic of the sprite spawner so
that the behaviour is the same.
```c#
internal class MissTextEffectSpawner : MonoBehaviour,
IFlyingObjectEffectDidFinishEvent
{
// There is a lot of data here that needs filling
private float duration;
private float xSpread;
private float targetYPos;
private float targetZPos;
private Color color;
private float fontSize;
private MissTextEffect.Pool missTextEffectPool;
public void SpawnText(
Vector3 pos, Quaternion rotation, Quaternion inverseRotation)
{
var text = "CUSTOM MISS";
var targetPos = rotation * new Vector3(
Mathf.Sign((inverseRotation * pos).x) * xSpread,
targetYPos,
targetZPos);
var missTextEffect = missTextEffectPool.Spawn();
missTextEffect.didFinishEvent.Add(this);
missTextEffect.transform.localPosition = pos;
missTextEffect.InitAndPresent(
text, duration, targetPos, rotation, color, fontSize, false);
}
public void HandleFlyingObjectEffectDidFinish(
FlyingObjectEffect flyingObjectEffect)
{
flyingObjectEffect.didFinishEvent.Remove(this);
missTextEffectPool.Despawn((MissTextEffect)flyingObjectEffect);
}
}
```
All we need to do is register these components in an installer. Let's create a `PlayerInstaller` and
add our bindings.
- Bind the `MissTextEffectSpawner` as a component on a single new game object
- Bind the memory pool for the `MissTextEffect` similar to the other score effects in the
`EffectPoolsManualInstaller`
```c#
internal class PlayerInstaller : Installer
{
public override void InstallBindings()
{
Container.Bind<MissTextEffectSpawner>()
.FromNewComponentOnNewGameObject()
.AsSingle();
Container.BindMemoryPool<MissTextEffect, MissTextEffect.Pool>()
.WithInitialSize(20)
.FromComponentInNewPrefab(GetMissTextEffectPrefab());
}
private static MissTextEffect GetMissTextEffectPrefab()
{
var prefabObject = new GameObject("MissTextEffect");
var textEffect = prefabObject.AddComponent<MissTextEffect>();
var textObject = new GameObject("Text") { layer = 5 };
textObject.transform.SetParent(prefabObject.transform, false);
textEffect.textMesh = textObject.AddComponent<TextMeshPro>();
textEffect.textMesh.alignment = TextAlignmentOptions.Capline;
textEffect.textMesh.fontStyle = FontStyles.Bold | FontStyles.Italic;
return textEffect;
}
}
```
Creating the `MissTextEffect` prefab here doesn't make much sense and should realistically move to its
own class but for now this is fine to demonstrate what we're doing.
Remember to add the zenjector to the `Plugin` init too.
```c#
[Plugin(RuntimeOptions.SingleStartInit), NoEnableDisable]
internal class Plugin
{
[Init]
public Plugin(Logger log, Config config,
PluginMetadata metadata, Zenjector zenjector)
{
log.Info($"{metadata.Name} {metadata.HVersion} initialized.");
zenjector.UseLogger(log);
zenjector.Install<PlayerInstaller>(Location.Player);
}
}
```
Now that we have the main components of the mod outlined, we need to set their fields. There are two ways
we can do this. We can do it manually by loading up a map in-game, opening [Runtime Unity Editor](rue.md),
and looking for the miss effect spawner to see the values. This may work, but we should figure out how to automate
it in case the values aren't constant.
As seen before, we found the prefab for the `FlyingSpriteEffect` in the `EffectPoolsManualInstaller`. This isn't
actually an installer, instead it's a part of the _much_ larger `GameplayCoreInstaller`.
If we were to patch in to the `GameplayCoreInstaller`, we can access the prefabs for the `FlyingTextEffect` and
the instance of the `FlyingSpriteSpawner` to get the fields we need for our custom components.
Since we're using SiraUtil for this mod, let's make an [affinity patch](./zenject.md#affinity-patching)
into the `InstallBindings()` method. We can take the fields from the prefabs and bind their values with an ID,
so that we can inject them into our own components.
```c#
internal class GameCoreInstallerHook : IAffinity
{
[AffinityPrefix]
[AffinityPatch(typeof(GameplayCoreInstaller), "InstallBindings")]
private void InstallBindingsPostfix(GameplayCoreInstaller __instance)
{
var container = __instance.Container;
var flyingSpriteSpawner = __instance._missedNoteEffectSpawnerPrefab._missedNoteFlyingSpriteSpawner;
var flyingTextEffect = __instance._effectPoolsManualInstaller._flyingTextEffectPrefab;
float duration = flyingSpriteSpawner._duration;
float spread = flyingSpriteSpawner._xSpread;
float targetYPos = flyingSpriteSpawner._targetYPos;
float targetZPos = flyingSpriteSpawner._targetZPos;
var color = Color.white;
const float fontSize = 4.5f; // Miss text is a sprite; estimate the font size
var fadeAnimationCurve = flyingTextEffect._fadeAnimationCurve;
var moveAnimationCurve = flyingTextEffect._moveAnimationCurve;
container.BindInstance(duration).WithId("missEffectDuration").AsCached();
container.BindInstance(spread).WithId("missEffectSpread").AsCached();
container.BindInstance(targetYPos).WithId("missEffectTargetYPos").AsCached();
container.BindInstance(targetZPos).WithId("missEffectTargetZPos").AsCached();
container.BindInstance(color).WithId("missEffectColor").AsCached();
container.BindInstance(fontSize).WithId("missEffectFontSize").AsCached();
container.BindInstance(fadeAnimationCurve).WithId("textEffectFadeAnimationCurve").AsCached();
container.BindInstance(moveAnimationCurve).WithId("textEffectMoveAnimationCurve").AsCached();
}
}
```
Make sure not to forget to bind this patch. Since we're patching the installer itself, binding it alongside
the installer we are patching won't work because the `InstallBindings` will be called before our patch is applied.
Instead let's make an `AppInstaller`, because that will be applied when the game initializes.
```c#
internal class AppInstaller : Installer
{
public override void InstallBindings()
{
Container.BindInterfacesTo<GameCoreInstallerHook>().AsSingle();
}
}
```
Remember to add this to the `Plugin` init too.
```c#
zenjector.Install<AppInstaller>(Location.App);
```
And now we add [inject methods](./zenject.md#methods) to our components, starting with the `MissTextEffect`. Note that
the `_moveAnimationCurve` is part of the base class. We need this so that the movement animation matches the base game's
movement.
```c#
[Inject]
public void Init(
[Inject(Id = "textEffectFadeAnimationCurve")] AnimationCurve fadeAnimationCurve,
[Inject(Id = "textEffectMoveAnimationCurve")] AnimationCurve moveAnimationCurve)
{
this.fadeAnimationCurve = fadeAnimationCurve;
_moveAnimationCurve = moveAnimationCurve;
}
```
And for `MissTextEffectSpawner` there are quite a few properties. Also, remember to inject the `Pool`.
```c#
[Inject]
public void Init(
[Inject(Id = "missEffectDuration")] float duration,
[Inject(Id = "missEffectSpread")] float xSpread,
[Inject(Id = "missEffectTargetYPos")] float targetYPos,
[Inject(Id = "missEffectTargetZPos")] float targetZPos,
[Inject(Id = "missEffectColor")] Color color,
[Inject(Id = "missEffectFontSize")] float fontSize,
MissTextEffect.Pool missTextEffectPool)
{
this.duration = duration;
this.xSpread = xSpread;
this.targetYPos = targetYPos;
this.targetZPos = targetZPos;
this.color = color;
this.fontSize = fontSize;
this.missTextEffectPool = missTextEffectPool;
}
```
Now we're all set up to implement our custom text effect. We just need to figure out how to spawn them. Ultimately,
the goal is to replace the game's "MISS" sprite effect with our own, so let's go back to the `MissedNoteEffectSpawner`
and patch it to replace the `FlyingSpriteSpawner` with our spawner by using a patch.
By using an affinity patch we can inject the `MissTextEffectSpawner` and use it within the patch with ease.
```c#
internal class OnMissEffectPatch : IAffinity
{
private readonly MissTextEffectSpawner missTextEffectSpawner;
public OnMissEffectPatch(MissTextEffectSpawner missTextEffectSpawner)
{
this.missTextEffectSpawner = missTextEffectSpawner;
}
[AffinityPrefix]
[AffinityPatch(typeof(MissedNoteEffectSpawner), nameof(MissedNoteEffectSpawner.HandleNoteWasMissed))]
private bool HandleNoteWasMissedPrefix(MissedNoteEffectSpawner __instance, NoteController noteController)
{
if (noteController.hidden
|| noteController.noteData.time + 0.5f < __instance._audioTimeSyncController.songTime
|| noteController.noteData.colorType == ColorType.None)
{
// Do nothing
return false;
}
var position = noteController.inverseWorldRotation * noteController.noteTransform.position;
position.z = __instance._spawnPosZ;
// Spawn our miss text effect
missTextEffectSpawner.SpawnText(
noteController.worldRotation * position,
noteController.worldRotation,
noteController.inverseWorldRotation);
// Cancel the original implementation
return false;
}
}
```
Apart from being syntactically different to the original method we can see from the decompiler, the logic is the same.
We can bind this in the `PlayerInstaller` because this method runs during gameplay, and that's where our effect spawner
is bound too.
```c#
Container.BindInterfacesTo<OnMissEffectPatch>().AsSingle();
```
### Testing
At this point we should be able to see this in action. Open the game with [FPFC](./index.md#launch-args) and open any map.
Using No Fail will help.
![Testing Miss Text](/.assets/images/modding/pc-mod-tutorial-test.jpg 'Testing Miss Text')
## Adding Settings
There are many ways to add interactive menus in to the game, which you can see in the [UI section of this wiki](./bsml.md#adding-menus).
For this guide we will be using a [custom flow coordinator](./bsml.md#custom-flow-coordinator) which will provide plenty
of space to add more features to the UI in the future if we need to.
Before creating the UI, let's decide what features we need it to have.
- We want a setting to toggle the mod off and on - this is for the player's convenience and most mods should have one
- We need a way to input text to change the miss text, we can use the [ModalKeyboard](https://monkeymanboy.github.io/BSML-Docs/Tags/ModalKeyboardTag/)
for this
- As well as the input, we should also have some [Text](https://monkeymanboy.github.io/BSML-Docs/Tags/TextTag/) to show
the current miss text
- And finally, we need a way to open the modal keyboard. A simple [Button](https://monkeymanboy.github.io/BSML-Docs/Tags/ButtonTag/)
can do this
### Creating A Config
To make settings that will save between sessions, we can utilize BSIPA's config. Let's create a config class, and
add it to the plugin init. Instead of making a static config, we should pass it as a param of the `AppInstaller`, then bind
it there so we can inject it anywhere.
```c#
[assembly: InternalsVisibleTo(GeneratedStore.AssemblyVisibilityTarget)]
namespace MissTextChanger;
internal class PluginConfig
{
public virtual bool Enabled { get; set; } = true;
public virtual string MissText { get; set; } = "MISS";
}
```
Then add it to the `Plugin` init:
```c#
var pluginConfig = config.Generated<PluginConfig>();
zenjector.Install<AppInstaller>(Location.App, pluginConfig);
```
And in the installer:
```c#
internal class AppInstaller : Installer
{
public AppInstaller(PluginConfig pluginConfig)
{
this.pluginConfig = pluginConfig;
}
public override void InstallBindings()
{
Container.BindInstance(pluginConfig).AsSingle();
/* ... */
```
### Implementing The Settings
Before we mess around with the UI, let's make sure we can make these new features work. First, inject the config
into the `PlayerInstaller` so we can use it to stop our bindings from being made:
```c#
internal class PlayerInstaller : Installer
{
private readonly PluginConfig pluginConfig;
public PlayerInstaller(PluginConfig pluginConfig)
{
this.pluginConfig = pluginConfig;
}
public override void InstallBindings()
{
if (!pluginConfig.Enabled) return;
/* ... */
```
You can go to the config `.json` file in the `UserData` folder and tweak the settings manually to test that this
is working. Next, let's add the config to the `MissTextEffectSpawner` and use the text property in the
`SpawnText()` method.
```c#
public void SpawnText(
Vector3 pos, Quaternion rotation, Quaternion inverseRotation)
{
/* ... */
var text = pluginConfig.MissText;
missTextEffect.InitAndPresent(text, duration, targetPos, rotation,
color, fontSize, false);
/* ... */
}
```
That was simple thanks to zenject. Now let's move on to setting up the UI.
### Adding The UI
Now we will set up the flow coordinator so that we can start playing around with the BSML immediately.
Let's start at the end of the dependency tree with the view controller.
```xml
<vertical>
<toggle-setting value="Enabled" text="Enabled" apply-on-change="true"/>
<text text="~KeyboardInput" id="MissText" align="Capline" font-size="8"
italics="true" bold="true"/>
<button click-event="ShowInputKeyboard" text="change..."
pref-height="10" pref-width="27"/>
</vertical>
<modal-keyboard value="KeyboardInput" show-event="ShowInputKeyboard"/>
```
And the host for our view:
```c#
[HotReload(RelativePathToLayout = @".\settingsView.bsml")]
[ViewDefinition("MissTextChanger.Menu.settingsView.bsml")]
internal class SettingsViewController : BSMLAutomaticViewController
{
[Inject] private readonly PluginConfig pluginConfig = null!;
[UIComponent("MissText")] private readonly TextMeshProUGUI missText = null!;
[UIAction("#post-parse")]
private void PostParse()
{
SetMissTextPreview(pluginConfig.MissText);
}
private bool Enabled
{
get => pluginConfig.Enabled;
set => pluginConfig.Enabled = value;
}
private string KeyboardInput
{
get => pluginConfig.MissText;
set
{
pluginConfig.MissText = value;
SetMissTextPreview(value);
}
}
private void SetMissTextPreview(string v) =>
missText.text = string.IsNullOrEmpty(v) ? "<alpha=#AA>No miss text" : v;
}
```
By using the `HotReload` attribute we can get live updates to the view controller when we change the bsml file,
without having to re-build the mod.
Now for the `FlowCoordinator`, which is responsible for managing our view controller.
```c#
internal class MissTextChangerFlowCoordinator : FlowCoordinator
{
[Inject] private readonly SettingsViewController settingsViewController = null!;
public event Action? DidFinish;
protected override void DidActivate(bool firstActivation, bool addedToHierarchy, bool screenSystemEnabling)
{
if (firstActivation)
{
showBackButton = true;
SetTitle("MissTextChanger");
}
if (addedToHierarchy)
{
ProvideInitialViewControllers(settingsViewController);
}
}
protected override void BackButtonWasPressed(ViewController topViewController)
{
DidFinish?.Invoke();
}
}
```
We're using an action here to signal when we are done so that we don't need an extra dependency to handle returning to the
main flow coordinator. This event will be used by our button manager.
```c#
internal class MenuButtonManager : IInitializable, IDisposable
{
private readonly MainFlowCoordinator mainFlowCoordinator;
private readonly MissTextChangerFlowCoordinator missTextChangerFlowCoordinator;
private readonly MenuButtons menuButtons;
private readonly MenuButton menuButton;
public MenuButtonManager(
MainFlowCoordinator mainFlowCoordinator,
MissTextChangerFlowCoordinator missTextChangerFlowCoordinator,
MenuButtons menuButtons)
{
this.mainFlowCoordinator = mainFlowCoordinator;
this.missTextChangerFlowCoordinator = missTextChangerFlowCoordinator;
this.menuButtons = menuButtons;
menuButton = new("MissTextChanger", PresentFlowCoordinator);
}
public void Initialize()
{
menuButtons.RegisterButton(menuButton);
missTextChangerFlowCoordinator.DidFinish += DismissFlowCoordinator;
}
public void Dispose()
{
missTextChangerFlowCoordinator.DidFinish -= DismissFlowCoordinator;
}
private void PresentFlowCoordinator() =>
mainFlowCoordinator.PresentFlowCoordinator(missTextChangerFlowCoordinator);
private void DismissFlowCoordinator() =>
mainFlowCoordinator.DismissFlowCoordinator(missTextChangerFlowCoordinator);
}
```
That's everything we need to create our UI. Now we just need a `MenuInstaller` to create the bindings.
```c#
internal class MenuInstaller : Installer
{
public override void InstallBindings()
{
Container.BindInterfacesTo<MenuButtonManager>().AsSingle();
Container.Bind<MissTextChangerFlowCoordinator>().FromNewComponentOnNewGameObject().AsSingle();
Container.Bind<SettingsViewController>().FromNewComponentAsViewController().AsSingle();
}
}
```
And of course, remember to add this to the `Plugin` init.
```c#
zenjector.Install<MenuInstaller>(Location.Menu);
```
![Tutorial Menu Screenshot](/.assets/images/modding/pc-mod-tutorial-menu.jpg 'Tutorial Menu Screenshot')
## Closing Remarks
We have now covered every step of creating a new Beat Saber mod.
This example mod has been designed in a way which allows easy changes and extension to its features. When designing a mod,
it's important to figure out what you want to do so that development doesn't reach a halt.
If you want to learn more we highly recommend checking the source code for other mods to learn more about different APIs
and how Beat Saber works. You can find that most mods are open source, and you can find that source by visiting
[BeatMods](https://beatmods.com/) and going to the more info section for any given mod.
You can view all of the source code used in this guide [here](https://github.com/qqrz997/TutorialPCMod).
+234
View File
@@ -0,0 +1,234 @@
---
prev: Decompiling
next: Creating UI
---
# Harmony Patching
A common method of altering the behavior of the game is through the Harmony API, and every modder should know how
to use it.
## Summary
Harmony patching is a way of hooking the games methods and pointing them to different implementations.
By writing harmony patches, you are essentially adding code to methods, changing parts of them, or entirely rewriting
them.
Harmony patches are quite powerful and are used in a great amount of different mods. There is a lot of detail about
patching and you should read the [documentation](https://harmony.pardeike.net/articles/patching.html) if you ever need
to do something specific.
## Harmony Setup
There are different methods of setting up your patches as stated
[here](https://harmony.pardeike.net/articles/basics.html#patching-using-annotations)
in the documentation. We are simply going to patch all methods marked with the `HarmonyPatch` attributes using
`PatchAll()`:
```c#
internal class Plugin
{
private Harmony harmony;
private Assembly executingAssembly = Assembly.GetExecutingAssembly();
[Init]
public Plugin(PluginMetadata pluginMetadata)
{
harmony = new Harmony(pluginMetadata.Id);
}
[OnStart]
public void OnApplicationStart() => harmony.PatchAll(executingAssembly);
[OnExit]
public void OnApplicationQuit() => harmony.UnpatchSelf();
}
```
Now, any classes and methods with the `HarmonyPatch` attribute will be registered as a patch. Once again, if you want
more control, there are different methods to do this as stated in the
[documentation](https://harmony.pardeike.net/articles/basics.html#manual-patching).
## Examples
To make understanding harmony patches easier, we will provide some simple examples of the different things you can do.
### Postfix
The simplest method of patching involves adding code at the end of a method - a postfix.
- This is very commonly seen and can be used as events to execute code when the game does certain events
- It can be used to reliably get references to objects without having to use expensive methods like
[`Resources.FindObjectsOfTypeAll`](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/Resources.FindObjectsOfTypeAll.html).
- They can also change the return result of methods as mentioned in the
[documentation](https://harmony.pardeike.net/articles/patching-postfix.html).
The following patch patches the `Init()` method in any `NoteController`. The patch gets a reference to the instance of
the object by injecting the `__instance` variable in the patch params.
Since `NoteController` is a type that has many inheritors, we can get what type of note controller it is. If you run
this in a map that also has bombs and chains, you will see their types get listed in the logs too.
```c#
[HarmonyPatch(typeof(NoteController), "Init")]
public class ExamplePatch
{
public static void Postfix(NoteController __instance)
{
Plugin.Log.Info($"A {__instance.GetType().Name} has been initialized.");
}
}
```
### Prefix
Another common patch, this is very similar to a postfix except it runs before the original method.
- This allows you to decide dynamically decide whether the original implementation should run or not
- Like with a postfix, you can also decide the result yourself
- You can create a state variable that can be passed to a postfix of the same method
The following example patches the `RefreshScore()` method in the `FlyingScoreEffect`, which is the MonoBehaviour
attached to the text that displays your score when you cut a note. We get a reference to the instance, and also
the original method params: `score` and `maxPossibleCutScore`.
The patch is pretty self explanatory, but when you score the max possible score for a note - which is 115 for
a normal note - the text will be replaced with `Hello World!`, and the original method will be ignored. If the score
does not reach the max possible score, then the original method will be called instead.
```c#
[HarmonyPatch(typeof(FlyingScoreEffect), "RefreshScore")]
public class ExamplePatch
{
public static bool Prefix(FlyingScoreEffect __instance, int score, int maxPossibleCutScore)
{
if (score >= maxPossibleCutScore)
{
__instance._text.text = "Hello World!";
__instance._colorAMultiplier = 1f;
// Cancel the original method
return false;
}
// Run the original implementation
return true;
}
}
```
### Transpiler
The last commonly used patch we will mention is the transpiler. These are used to modify the
[CIL](https://en.wikipedia.org/wiki/Common_Intermediate_Language) code of the game directly. With these, you can
make changes in the middle of methods.
This type of patch is much more complicated, and we won't provide an example here (for now), but we can recommend
checking out transpilers from other mods. As always, if you want to learn more about transpilers, check the
[documentation](https://harmony.pardeike.net/articles/patching-transpiler.html).
## Accessing Private Code
When making mods you often will need to alter `private` fields, or call `private` methods. Thankfully, in C# there are
some methods that allow us to do this.
### Publicizing Assemblies
The easiest and recommended way to access `private` members is by utilizing the `BepInEx.AssemblyPublicizer.MSBuild`
NuGet package. To add this to your project, do one of the following:
- Navigate to your project dependencies in the assembly explorer, right click it, and select `Manage NuGet Packages`.
Then, search for "BepInEx.AssemblyPublicizer.MSBuild", right click it, and select install;
- or navigate to the top bar and look for `Tools | NuGet | Manage NuGet Packages for Solution` and search for it there.
Alternatively, you can add it manually in the `.csproj` project file manually by adding a `PackageReference`:
```xml
<PackageReference Include="BepInEx.AssemblyPublicizer.MSBuild" Version="0.4.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
```
Once installed, all you have to do is add the `Publicize` property to an assembly reference like this:
```xml
<Reference Include="Main" Publicize="true">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Main.dll</HintPath>
<Private>false</Private>
</Reference>
```
Now, anything that was `private` or `protected` will be seen as `public` by the compiler, allowing you to bypass this
restriction.
The only restriction you will run in to now is with `readonly` fields and auto-computed properties (see below).
If you want to set the value of these, you will have to use [reflection](#reflection).
```c#
public readonly float _field;
public float Property { get; }
```
::: danger IMPORTANT
**Do not use the assembly publicizer to publicize other mods**. This can cause some problems with the mod loader. Instead,
use [reflection](#reflection) or make a request to the mod's maintainer to add a change if you need it.
:::
### Reflection
Reflection is a special feature of C# that lets you read code at runtime by making types into objects which you can access
members from. There is a lot you can do with reflection, and something that it is commonly used for is checking if certain
parts of another mod's code are running without actually having to reference that mod's assembly.
If you want to read more about reflection you can check
[Microsoft's docs](https://learn.microsoft.com/en-us/dotnet/csharp/advanced-topics/reflection-and-attributes/).
IPA provides some utilities to use reflection to get and set values of members, and invoke methods, even if they are private.
```c#
using IPA.Utilities;
```
Now we can use the `ReflectionUtil` class, which provides a couple extension methods to pretty easily access private members
of an object.
```c#
public class SomeClass
{
private float someValue = 0.25f;
public float SomeValue => someValue;
}
```
Let's say we had a reference to an object of type `SomeClass`, we can access and set the private field by using `SetField`,
this works even when the field is `readonly`.
```c#
var someClass = new SomeClass();
someClass.SetField("someValue", 0.5f);
```
::: tip NOTE
If you are just reading the values of members, accessing methods, or setting the values of **non-readonly** fields and properties,
you should use the [Assembly Publicizer](#publicizing-assemblies), because it is easier to read, is faster, and creates less
garbage.
:::
If you must set a field often or repetitively with reflection, you should use the `FieldAccessor` to reduce the
performance cost. You create the accessor by providing the type of the object the field is on, and the backing type of
the field, as well as the name of the field itself.
```c#
private static FieldAccessor<SomeClass, float>.Accessor SomeValueAccessor { get; } =
FieldAccessor<SomeClass, float>.GetAccessor("someValue");
private SomeClass someClass = new();
public void SomeMethod()
{
SomeValueAccessor(ref someClass) = 1f;
}
```
+74
View File
@@ -0,0 +1,74 @@
---
prev: false
next: false
description: Learn how to create your own PC mods!
---
# Making PC Mods
Currently, all mods are made using the
[BSIPA (Beat Saber Illusion Plugin Architecture)](https://github.com/nike4613/BeatSaber-IPA-Reloaded/)
to inject plugins into the game. It makes the process of executing code in game much easier, and provides many useful
tools, some of which will be covered in this section of the wiki.
## List of contents
- [Getting a setup ready for creating PC mods](#getting-started)
- [Useful launch arguments](#launch-args)
- [Using Runtime Unity Editor](./rue.md)
- [Inspecting the game code with a decompiler](./decompiling.md)
- [Harmony patching](./harmony-patching.md)
- [Creating user interfaces with BeatSaberMarkupLanguage](./bsml.md)
- [The essentials of Zenject through SiraUtil](./zenject.md)
- [Writing a functioning mod step-by-step](./full-mod-guide.md)
- [Other links](#other-links)
## Getting Started
If you are interested in creating a Beat Saber mod, but do not have a template or Visual Studio template set up,
follow the [setup guide](./setup.md) to get your project all set up.
If you have any questions at any point, the best place to ask is in the `#pc-mod-dev` channel on the
[BSMG Discord](https://discord.gg/beatsabermods), another modder may be able to help you solve your problem.
## Launch args
Listed in the table below are numerous helpful launch arguments that will make modding / debugging easier.
If you are using Steam, you can enter these by right-clicking the game in Steam, then `Properties...`, then `General`.
If you are using BSManager, you can enter these by opening the `Advanced launch` option on the game launch section.
BSManager also already provides FPFC and Debug modes, which correspond to `fpfc` and `--verbose` respectively.
<!-- markdownlint-disable MD013 -->
| Argument&emsp;&emsp;&emsp; | Description |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--verbose` | Enables the output log window for IPA. This will show the debug console that mods use. This is a must-have for all modders. |
| `--debug` | Enables 'debug' level logs to show up in the log output window. These would otherwise normally only show up in log files. |
| `--trace` | Enables 'trace' level logs to show up in the log output window. These are typically reserved for overly-detailed logs. |
| `fpfc` | The "First Person Flying Controller" is a base-game feature that allows you to use WASD and mouse to control the camera without VR. This makes for easy testing. |
| `--auto_play` | A base-game feature since version 1.37.1, it enables a basic auto player. This is useful for testing gameplay without playing yourself. |
| `-vrmode oculus` | Only works on versions 1.29.1 and older. Allows you to play without SteamVR when playing the game from Steam. |
<!-- markdownlint-enable MD013 -->
## Other Links
Notable links mentioned in the PC modding wiki:
- [BSIPA Documentation](https://nike4613.github.io/BeatSaber-IPA-Reloaded/articles/start-dev.html)
- [JetBrains Rider](https://www.jetbrains.com/rider/)
- [BSMT For JetBrains Rider](https://github.com/Fernthedev/BSMT-Rider/)
- [BSMT For Visual Studio](https://github.com/Zingabopp/BeatSaberTemplates/)
- [C# Documentation](https://learn.microsoft.com/en-us/dotnet/csharp/)
- [Unity Scripting API](https://docs.unity3d.com/ScriptReference/index.html)
- [Runtime Unity Editor](https://github.com/ManlyMarco/RuntimeUnityEditor)
- [ILSpy](https://github.com/icsharpcode/ILSpy)
- [dnSpy](https://github.com/dnSpyEx/dnSpy)
- [Harmony](https://github.com/pardeike/Harmony)
- [Harmony Documentation](https://harmony.pardeike.net/articles/patching.html)
- [Zenject](https://github.com/Mathijs-Bakker/Extenject)
- [BSMG Discord](https://discord.gg/beatsabermods)
- [BeatMods](https://beatmods.com)
- [BeatMods Approval Guidelines](https://docs.google.com/document/d/15RBVesZdS-U94AvesJ2DJqcnAtgh9E2PZOcbjrQle5Y/edit?usp=sharing)
+35
View File
@@ -0,0 +1,35 @@
---
prev: Setup Guide
next: Decompiling
---
# Object Inspectors
An essential tool for modding is a game object inspector.
## Runtime Unity Editor
[Runtime Unity Editor (RUE)](https://github.com/ManlyMarco/RuntimeUnityEditor) is a tool that we can use to look at different
components in-game while playing. It will allow us to find objects by name, components attached to GameObjects, and
tweak properties of these while the game is running.
It's important to get used to using RUE because figuring out the game through code will take ten times as much trial-and-error.
In order to get RUE, currently, you can download it from a pinned message you will find in the `#pc-mod-dev` channel in the
[BSMG discord](https://discord.gg/beatsabermods).
You will have to [manually install](../../pc-modding.md#manual-installation) RUE by dragging the Libs and Plugins within
the zip into your game. Once installed, you can open the game in FPFC mode, then press `G` to open RUE.
You can configure the keybinding in `/UserData/Runtime Unity Editor (BSIPA).json` which is recommended because `G` is also
the default keybinding for SiraUtil's FPFC toggle feature.
![RUE Screenshot 1](/.assets/images/modding/pc-mod-rue1.jpg 'RUE Screenshot 1')
![RUE Screenshot 2](/.assets/images/modding/pc-mod-rue2.jpg 'RUE Screenshot 2')
## UnityExplorer
An alternative to Runtime Unity Editor is [UnityExplorer](https://github.com/yukieiji/UnityExplorer),
which is also regularly used for Beat Saber modding. You can find all details on how to install UnityExplorer
[here](https://github.com/yukieiji/UnityExplorer?tab=readme-ov-file#standalone), but, because we use BSIPA, you will
have to either build it yourself or search around in BSMG for someone who has already done this.
+183
View File
@@ -0,0 +1,183 @@
---
prev: false
next: Runtime Unity Editor
---
# PC Mod Development Intro
_Learn how to get started writing your own PC Mods._
## Getting Started
::: warning
This guide is for making mods for the **PC** version of Beat Saber!
If you want to develop mods for the **Quest Standalone** version of the game, visit
the [Quest Mod Development Guide](../quest/intro.md)
Make sure your game is modded before trying to make a mod.
See instructions for [modding Beat Saber on PC.](../../pc-modding.md)
This guide assumes you have a basic to intermediate understanding of C# and Unity.
You may have difficulty understanding what is covered here if you do not have this foundation.
:::
Beat Saber is made in Unity 2022.3 using C# with .NET framework 4.7.2. To make writing and building mods as simple as
possible you will need to download an IDE that supports Unity.
This guide
will be focused on [JetBrains Rider](https://www.jetbrains.com/rider/), however you can also
use [Microsoft Visual Studio Community](https://visualstudio.microsoft.com/). Both of these are good options, however,
the guide for Rider users is more up-to-date.
We will now cover setting up Rider for modding. For Visual Studio users, refer to the
[Visual Studio Setup](./vs-setup.md) page.
## Modding Tools Setup
We will be using the BeatSaberModdingTools (BSMT) extension in this tutorial, as it comes with modding templates and
useful features, like saving your Beat Saber directory.
Firstly, download and install [Rider](https://www.jetbrains.com/rider/) from their website. Rider is free for
non-commercial use.
The Rider extension can be downloaded from [GitHub](https://github.com/Fernthedev/BSMT-Rider/releases/latest). Download
the BSMT Rider zip.
Once you have installed Rider open it and, after signing in, you will be greeted by the welcome window. In the bottom
left, click `Configure`, click `Settings`, then look for `Plugins`.
Next to `Marketplace` and `Installed` there will be a settings icon, click this, and click
`Install Plugin from Disk...`. From here, find the BSMT Rider zip you downloaded and select it, this will install the
plugin in Rider.
![Install Disk Plugin](/.assets/images/modding/pc-mod-rider-plugin.png 'Install Disk Plugin')
## Template setup
BSMT comes with some working plugin templates to get you started as quickly as possible.
Create a new solution and, if you installed BSMT correctly, you should be able to select a plugin template from the
`Custom Templates` list. We are going to use the bare template in this example and, later on, we will be building
[a functional mod completely from scratch](./full-mod-guide.md).
![Rider Modding Template Select](/.assets/images/modding/pc-mod-template-rider.png 'Modding Template Select')
Choose a name for your mod and the location you want to save it. Do not save the solution in your Beat Saber
installation folder lest you lose it.
Once you're done, click `Create` and the mod template will open. Next, you will receive a popup asking you to set your
Beat Saber Directory.
![Rider Beat Saber Directory](/.assets/images/modding/pc-mod-directory-rider.png 'Rider Beat Saber Directory')
Select your Beat Saber game's installation, you can also use a BSManager instance here too. If you select
`Store this beat saber folder in config`, BSMT will remember this directory whenever you reopen a project.
At this point, **try and build the project**, and it should automatically find the
references for you and the build should succeed if you set a valid Beat Saber installation directory. You can do this
with the build hotkey or the button on the top bar.
![Rider Build](/.assets/images/modding/pc-mod-build-rider.png 'Rider Build')
If you get any immediate errors, you may want to double-check the Beat Saber directory you provided. You can change it
by navigating to the `Tools` section at the top of the Rider window, and locating the `BSMT Project Tools` option. If
you still get errors, you can try restarting Rider.
Once again, if you have any issues you can't resolve, you can always
ask questions in the `#pc-mod-dev` channel in the BSMG discord.
If you need to manually add Beat Saber assembly or other mod references, right click on `Dependencies` in the Project
folder, then `Add Beat Saber assembly references`. This will let you search for Beat Saber assemblies, and it will add
them to the `.csproj` for you.
![Rider References](/.assets/images/modding/pc-mod-references-rider.png 'Rider References')
## Inspecting the Code
Open the explorer on the right side of Rider and you should see all the project files.
| Filename | About |
| ------------------------ | ------------------------------------------------------------------------------- |
| `PluginName.csproj` | This is the C# project that contains build information. |
| `PluginName.csproj.user` | This is where the Beat Saber directory is saved. BSMT will manage this for you. |
| `Plugin.cs` | The main file that is loaded for your mod. This is the entry point for BSIPA. |
| `Directory.Build.props` | Contains metadata for your plugin like the version, links, dependencies etc. |
## Edit your mod's manifest
### Defining Metadata
Open `Directory.Build.props` and fill in your mod's information in the Plugin Metadata `PropertyGroup`:
- The `PluginId` and `PluginName` keys are used to identify your mod. Mods that will be uploaded to BeatMods typically
should have these be exactly the same and have no spaces.
- The `Authors` is where you use your name.
- The `Version` is the version of your mod. This follows [Semantic Versioning](https://semver.org).
- The `GameVersion` is the exact version of the game you are making the mod for. It's recommended to make mods for the latest
version of the game with mod support.
- In the `Description` provide a short sentence or two about what your mod is/does.
There are also some optional properties you can add:
- The `ProjectSource` is a URL to the source code of your mod. Most mods have their source code open on GitHub, for
instance.
- The `ProjectHome` can be a URL to a website where your mod is downloaded from or hosted.
- You can also specify a `Donate` URL, which if you want to, you can set up a way for people to support your modding
work.
- The `PluginIcon` is a path to a `.png` file that can be pulled from your plugin.
### Defining Dependencies
Underneath the plugin metadata is an `ItemGroup` that declares which other mods are required for your mod to work.
::: warning
Do not remove the dependency on BSIPA. This is required by BSIPA itself.
:::
The template in this case only needs BSIPA to work. Add additional `DependsOn` members for each dependency.
Some example mod libraries that are commonly used could be BeatSaberMarkupLanguage, which is used to generate custom
menus in Beat Saber, or SiraUtil, which is used to interface with the game's Zenject system to easily access certain
game objects and build robust large plugins. These will be briefly covered with some examples later on this wiki.
### Additional Properties
- If your mod breaks in the presence of another mod due to conflicting behavior, you should add it as a `ConflictsWith`
member, which will make your plugin not load if the specified conflicting mod is installed.
- If your mod interacts with other mods but does not need them in order to function, consider adding `LoadAfter` to
ensure your mod doesn't try to interact with them before they are loaded by BSIPA.
- Similarly, you can add `LoadBefore` members to make your mod load before the specified mod.
- If you want to move `Plugin.cs` to somewhere else in the project, use `PluginHint` to specify where it is so that
BSIPA can find it.
- You can add numerous `RequiredFile` properties to specify external files required by the mod, typically used for libraries.
Once you've set all of this, BSMT will automatically generate an embedded `manifest.json` in your mod during build,
which is required by BSIPA and can be used to pull information about the mod.
This data can also be pulled from BSIPA to be used within your mod, and by other mods.
## Compiling
After running the build, your compiled DLL should automatically be copied to the `Plugins` folder in your Beat Saber
directory, which will be done for both debug and release builds.
When you are ready to release your mod, find the dropdown next to the build icon, and select the `Release` option to
make a Release build of your mod. Building in `Release` mode will generate a packaged `.zip` file ready to distribute.
This zip file should appear in `\bin\Release\net472\zip\` but you can always look at the build output tab to find the
zip destination directory.
## Testing your mod in-game
To test if your mod is loaded in-game, you will need to launch Beat Saber with the BSIPA Console enabled. For more
information on launch arguments, see [here](./index.md#launch-args).
When you launch the game, you should see BSIPA load your mod in the console window.
![Testing console screenshot](/.assets/images/modding/pc-mod-console-testing.png 'Testing console screenshot')
If you got this far, congratulations! You are now set up to create mods for Beat Saber.
From here, you should consider checking out the other pages of this wiki to learn about some of the libraries modders
use to add functionality to their mods, as well as learning to use some essential tools. If it helps, you can follow
the [full mod guide](./full-mod-guide.md) too, which will cover designing a mod from scratch.
+94
View File
@@ -0,0 +1,94 @@
---
prev: false
next: false
description: Learn how to create your own PC mods!
---
# Testing
It's important to make sure your mod doesn't unintentionally break base game functionality or other mods.
This page contains tips on how to test your mod properly.
## Testing Checklist
- If your mod affects something while playing a map, make sure to test all the modes (Solo, Campaign, Party, Online,
and Tutorial). There are variations between the modes so a class or an object you expect to exist might not be there
or could have a different name!
- Check that things still work properly after an internal restart. The easiest way to make the game internally restart
is to go to Settings and press OK. The game will destroy and recreate various objects when this happens so you need to
make sure your mod is picking up the new instances properly.
- Try to test with as many publicly available mods installed as you can. There might be some unexpected conflicts!
- Use a debug Unity build while testing as explained below.
## Using a Debug Unity Build
::: warning IMPORTANT
It is **highly recommended** to test your mod by using a debug Unity build, especially if you are doing any kind of
multithreading. It helps identify issues that can result in hard crashes to desktop that are otherwise very hard
to debug since Unity strips a lot of checks on release builds. Mods are tested using a debug build when being reviewed
for approval on [BeatMods](https://beatmods.com) and any exception thrown by Unity is grounds for denial.
:::
First, download the version of Unity the game is using. We highly recommend using
[Unity Hub](https://unity.com/unity-hub) to manage your Unity installations. The game's Unity version won't usually be
available directly in the Hub application since it's usually an older LTS version, but you can find all Unity versions
in [the Unity download archive](https://unity.com/releases/editor/archive). You can find the current version of Unity
the game is using by checking your logs; it'll be right above the list of plugins:
```log
...
[INFO @ 00:00:00 | IPA] Beat Saber
[INFO @ 00:00:00 | IPA] Running on Unity 2022.3.33f1
[INFO @ 00:00:00 | IPA] Game version 1.40.4
[INFO @ 00:00:00 | IPA] -----------------------------
[INFO @ 00:00:00 | IPA] Loading plugins from Plugins and found 1
[INFO @ 00:00:00 | IPA] -----------------------------
...
```
Once you've installed the required version of Unity, navigate to the install folder. In Unity Hub, you can do this
by going to the _Installs_ tab, pressing the cog on the top right corner of the version's box, and pressing
_Show in Explorer_.
![Unity Hub Installs Page](/.assets/images/modding/testing-unity-hub.png)
Once you've opened the folder, navigate to
`Data\PlaybackEngines\windowsstandalonesupport\Variations\win64_player_development_mono`. The contents should look
like below.
![Unity's win64_player_development_mono folder](/.assets/images/modding/testing-unity-playbackengine.png)
Select and copy the `UnityCrashHandler`, `UnityPlayer`, `WindowsPlayer`, and `WinPixEventRuntime` files as shown above,
then paste them into your Beat Saber installation's folder. This will overwrite some files; feel free to move or rename
the files that would be overwritten out if you want to swap between the release & debug builds more easily. Once you've
pasted the new files, delete/rename/move the `Beat Saber.exe` file, and rename `WindowsPlayer.exe` to `Beat Saber.exe`.
::: details Using a Batch script to swap between debug and release
If you want to swap between a release and a debug build often, you can use a batch script like the one below. Simply
add `.bak` to `UnityPlayer.dll`, `UnityCrashHandler64.exe`, and `Beat Saber.exe` (make sure you have file extensions
enabled in Windows Explorer or else this won't work properly), then copy the files from `win64_player_development_mono`
as explained above. Once that's done, create a new file called `debug.bat` (or whatever name you want as long as it
ends in `.bat`) and paste the contents below into that file. Double-click this new file to swap between the release
and debug builds.
```batch
move UnityPlayer.dll UnityPlayer.dll.tmp
move UnityPlayer.dll.bak UnityPlayer.dll
move UnityPlayer.dll.tmp UnityPlayer.dll.bak
move UnityCrashHandler64.exe UnityCrashHandler64.exe.tmp
move UnityCrashHandler64.exe.bak UnityCrashHandler64.exe
move UnityCrashHandler64.exe.tmp UnityCrashHandler64.exe.bak
move "Beat Saber.exe" "Beat Saber.exe.tmp"
move "Beat Saber.exe.bak" "Beat Saber.exe"
move "Beat Saber.exe.tmp" "Beat Saber.exe.bak"
```
:::
That's it! You should now be able to start the game as usual. If all went according to plan, you should see the
"Development Build" text at the bottom right of the screen when in FPFC, and whenever an error occurs the development
console will show up on the game window.
![Beat Saber running a debug build](/.assets/images/modding/testing-beat-saber-debug.jpg)
+76
View File
@@ -0,0 +1,76 @@
---
prev: false
next: false
---
# Modding Tools Setup for Visual Studio
We will be using the BeatSaberModdingTools (BSMT) extension in this tutorial,
as it comes with modding templates and useful features, like saving your Beat Saber directory.
The Visual Studio extension can be downloaded
from [GitHub](https://github.com/Zingabopp/BeatSaberTemplates/releases/latest). You will need to download
`BeatSaberModdingTools.vsix`. (Expand the Assets dropdown if you cannot find it)
Once downloaded, open the `.vsix` and it will install itself as a Visual Studio Plugin.
If you have any issues, consult the project's [README](https://github.com/Zingabopp/BeatSaberModdingTools#readme)
and [WIKI](https://github.com/Zingabopp/BeatSaberModdingTools/wiki).
## Template setup
BSMT comes with some working plugin templates to get you started as quickly as possible.
First, create a new project and find a template. We are going to use the `BSIPA4 Plugin (Core)` template, and we'll be
calling our mod `BSPlugin1`.
You should change the name to whatever you want to call your mod.
![VS Modding Template Select](/.assets/images/modding/modding-template-select.png 'Modding Template Select')
![VS Modding Template Name](/.assets/images/modding/modding-template-name.png 'Modding Template Name')
You will then need to set your Beat Saber Directory in Visual Studio.
Follow the instructions [on the template readme](https://github.com/Zingabopp/BeatSaberModdingTools#how-to-use),
or see the screenshot below.
![Setup Beat Saber Directory](/.assets/images/modding/setup-bs-directory.png 'Setup Beat Saber Directory')
At this point, **try and build the project**, and it should automatically find the
references for you and the build should succeed.
If your build does not succeed, check that you don't have any missing references.
::: tip
BeatSaberModdingTools will automatically handle references. If your references could not be
found, [double-check the instructions](https://github.com/Zingabopp/BeatSaberModdingTools#beat-saber-modding-tools).
If you need to manually add references, right click on `References` in the Project folder, then
`Beat Saber Reference Manager...`.
Select your references, then click "Apply".
You can find more information about the reference
manager [here](https://github.com/Zingabopp/BeatSaberModdingTools/wiki/Adding-References).
:::
## Inspecting the Code
You should have 5 files open automatically with the template.
| Filename | About |
| ------------------------ | ------------------------------------------------------------------------------ |
| `manifest.json` | Information about your mod for BSIPA. |
| `Plugin.cs` | The main file that is loaded for your mod. |
| `AssemblyInfo.cs` | File information about your mod. This is mostly managed by Modding Tools. |
| `PluginConfig.cs` | A template for enabling config for your mod. This is commented out by default. |
| `BSPlugin1Controller.cs` | A generic MonoBehaviour for your mod. |
### Edit your mod's Manifest
Fill out the `manifest.json` file with your information.
The `name` and `id` keys are used to identify your mod.
The ID should match the ID used when uploading your mod to BeatMods.
::: warning
Do **not** remove the dependency on BSIPA. As of BSIPA v4.1 this is required for your mod to load.
:::
After you're done with the setup, you can return to the main
[PC mod dev intro page](./setup.md#compiling) to find out how to run your mod in game!
+517
View File
@@ -0,0 +1,517 @@
---
prev: Creating UI
next: Step-by-step Mod Tutorial
---
# Zenject Introduction
Zenject is what is called a Dependency Injection (DI) Framework, and Beat Saber's code uses it extensively. You can read
more about DI on [Microsoft's docs](https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection) and
on [Wikipedia](https://en.wikipedia.org/wiki/Dependency_injection).
## What Is Dependency Injection
Trying to explain dependency injection usually makes it sound a lot more complex than it is. In short, it's when you delegate
the responsibility certain functionality in your code to "dependencies" and "injecting" them into objects upon their creation.
That is all DI is, but let's look at a simple C# example:
```c#
public interface IService
{
public int GetNumber();
}
internal class ServiceImplementation : IService
{
public int GetNumber()
{
// Implement this method
}
// Some other private behaviour
}
```
Now, we have an interface that provides the result of `GetNumber()`. Let's say we needed this behaviour in another object:
```c#
internal class SomeObject
{
private readonly IService service;
private readonly List<int> numbers = [];
public SomeObject(IService service)
{
this.service = service;
}
void Update()
{
if (numbers.Count > 5)
{
numbers.Clear();
}
int number = _service.GetNumber();
numbers.Add(number);
}
}
```
As we can see, when we create `SomeObject` we have to provide an instance of `IService` because it depends on the service.
The field `_numbers` is not a dependency in this case; it is just data that belongs to `SomeObject`.
This is essentially all you need to know to understand dependency injection, but the
[Zenject README](https://github.com/Mathijs-Bakker/Extenject?tab=readme-ov-file#what-is-dependency-injection)
goes a bit more in-depth about the what and why of DI.
By using dependency injection, you are able to more easily define the behaviour that each feature needs. If you need to
make changes in the future, your code will have enough abstractness that you should not have to go into every part of
the code to make everything work together.
## What Is Zenject
Now that you have some idea of what DI looks like, all Zenject does is makes the process of maintaining DI easy. Zenject
has a lot of different features but it would be pointless to cover them all here, but you can always check the
[GitHub README](https://github.com/Mathijs-Bakker/Extenject) to learn more about all of its features.
Zenject lets create us objects by declaring their "contract binding" in what they call an `Installer`. We can give keys
to dependencies, we can provide specific methods to create objects, we can declare multiple implementations of the same
interface, and more.
## Using Zenject In Mods
In order to easily access the game's implementation of Zenject, we use a library called [SiraUtil](https://github.com/Auros/SiraUtil).
This is used in a wide variety of mods and it allows us to take full advantage of dependency injection without much
extra effort.
Before doing anything, add an assembly reference to `SiraUtil`, `Zenject`, and `Zenject-usage`. Make sure you add
`SiraUtil` as a dependency in your plugin metadata.
### Implementing Zenject
First, add a `Zenjector` param to your plugin class `[Init]` method:
```c#
[Init]
public Plugin(Zenjector zenjector)
{
}
```
The `Zenjector` will allow you to access the game's `Installer`s and let you make your own bindings with them.
Let's now look at the class we will be using to test Zenject:
```C#
internal class Test : IInitializable
{
private readonly SiraLog log;
public Test(SiraLog log) { this.log = log; }
public void Initialize() => log.Info("Initializable test");
}
```
We pass a `SiraLog` instance to this object in the constructor. This is a service provided by SiraUtil, and acts
as an instance-based logger.
This class implements [IInitializable](https://github.com/Mathijs-Bakker/Extenject?tab=readme-ov-file#iinitializable),
which is an interface provided by Zenject. The `Initialize()` method gets called after all objects have been created,
and on Unity's [Start](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/MonoBehaviour.Start.html) event.
This is ideally where initialization logic for your object would go.
In this test case, all it does is log a message when created.
Let's make a binding to test this behaviour - we provide an installer and use the callback with the `DiContainer`
to make a binding:
```c#
public Plugin(Zenjector zenjector)
{
zenjector.Install<StandardGameplayInstaller>(container =>
{
container.Bind<IInitializable>().To<Test>().AsSingle();
});
}
```
What we are doing here is binding `Test` with its `IInitializable` interface on the DiContainer for the
`StandardGameplayInstaller`. The `AsSingle` method ensures only one instance of `Test` can be bound.
If you build this now and play any map in solo, you will see the "Initializable test" message appear in the console
when the scene transition ends.
However, the `SiraLog` that we used doesn't have a base logger to use, so the
source appears as `???`. In order to fix this, we can just provide the `Zenjector` with IPA's logger:
```c#
zenjector.UseLogger(logger);
```
### Cleaning Up
It's recommended to organize your bindings in your own installers. Create an installer, and override the
`InstallBindings()` method:
```c#
internal class TutorialInstaller : Installer
{
public override void InstallBindings()
{
Container.BindInterfacesTo<Test>().AsSingle();
}
}
```
We have also made use of the [BindInterfacesTo](https://github.com/Mathijs-Bakker/Extenject?tab=readme-ov-file#bindinterfacesto-and-bindinterfacesandselfto)
method here, which is just a shortcut so you don't have to remember what interfaces your type implements. It is good to know
the full expression in case you want to make it clear that you are implementing an interface that will be used as a
dependency throughout your code.
Now, we just specify the installer to the `Zenjector` with either a base installer to install upon, or by using the
`location` enum argument to specify a common location:
```c#
public Plugin(Zenjector zenjector, IPALogger logger)
{
zenjector.UseLogger(logger);
zenjector.Install<TutorialInstaller>(Location.StandardPlayer);
}
```
By doing this we have made the `Plugin` class just responsible for defining the contexts in which the plugin operates
in, whilst the installers declare the interface of the code.
## Types Of Injection
So far we've only covered injecting dependencies through a constructor, however, there are multiple ways to achieve this
goal with Zenject.
### Constructors
As covered before, constructor injection is the main form of injection. They force the dependencies to only be resolved
at object creation, the dependencies are immediately apparent, and they guarantee no circular dependencies which
encourages better design.
```c#
internal class SomeObject
{
private readonly IService service;
public SomeObject(IService service) { this.service = service; }
}
internal record SomeOtherObject(IService Service);
```
Unfortunately, MonoBehaviours cannot have constructors, so you are left with method and field injection for those.
### Methods
The `Inject` attribute can be used on methods, and with it we can treat methods just like constructors by supplying the
dependencies in the params for the method.
```c#
internal class SomeBehavior : MonoBehaviour
{
private IService service = null!;
[Inject]
public void Init(IService service) { this.service = service; }
}
```
As you can see, this example is using a `MonoBehaviour`. Since MonoBehaviours cannot have constructors, this is the
preferred way to do injection on them. It looks a lot like a constructor which makes the intention of this code
slightly more clear. That being said, you can use field injection on MonoBehaviours too.
A problem with this approach is that you can't make the field readonly. This can make the code's intent less clear,
as a field that isn't readonly implies it might be open to changing; you usually aren't going to be changing the
value of dependencies.
### Fields And Properties
Field and property injections occur directly after the constructor finishes. This is achieved by adding `[Inject]` to any
field or property.
```c#
internal class SomeBehavior : MonoBehaviour
{
[Inject]
private readonly IService service = null! // assigned by Zenject
}
```
Since Zenject uses [reflection](https://learn.microsoft.com/en-us/dotnet/fundamentals/reflection/reflection)
to set these fields, you can make them private and readonly. This is great for demonstrating the intention of the code,
but field injection can look a bit cryptic for others looking at the code.
## Common DiContainer Methods
There are dozens of methods to create a binding as seen in the documentation, so let's highlight a few ways of creating
bindings that you will be mostly using.
<!-- markdownlint-disable MD013 -->
| Name | Description |
| ---------------------------------- | --------------------------------------------------------------------------------------------------- |
| `Bind<T>` | Registers the type `T` for injection for itself and other types |
| `BindInstance` | Registers the type of the provided existing object instance |
| `BindInterfacesTo<T>` | Registers the interfaces for the type `T` for injection |
| `BindInterfacesAndSelfTo<T>` | A combination of `Bind<T>` and `BindInterfacesTo<T>` |
| `AsCached` | The same instance of the object will be reused |
| `AsSingle` | The same as `AsCached` but ensures only one binding can be made for the result type |
| `AsTransient` | Instances of the result type will not be reused; a new one will be created each time it's requested |
| `FromNewComponentOnNewGameObject` | Create an empty `GameObject` and add a new component of the result type on it |
| `FromNewComponentAsViewController` | Provided by SiraUtil; creates a new view controller - result type must inherit `ViewController` |
<!-- markdownlint-enable MD013 -->
## Zenject With UI
Once you have your SiraUtil setup, you can easily declare all menu-related code in a installer in the menu.
```c#
zenjector.Install<MenuInstaller>(Location.Menu);
```
### Binding View Controllers
SiraUtil provides a way to create view controllers easily using `FromNewComponentAsViewController`. You
can also bind a flow coordinator, but since it is a `MonoBehaviour`, you should use `FromNewComponentOnNewGameObject`,
or any compatible construction method.
```c#
internal class MenuInstaller : Installer
{
public override void InstallBindings()
{
Container.Bind<TutorialViewController>().FromNewComponentAsViewController().AsSingle();
Container.Bind<TutorialFlowCoordinator>().FromNewComponentOnNewGameObject().AsSingle();
Container.BindInterfacesTo<MenuButtonManager>().AsSingle();
}
}
```
Now, we would be able to inject our view controllers into the flow coordinator, and we can also inject the
`MainFlowCoordinator` to make use of it for the menu button.
Additionally, as seen before with the `SiraLog`, we can use bindings made by other mods. Another case is the `MenuButtons`
class from BSML:
```c#
internal class MenuButtonManager : IInitializable
{
private readonly MenuButtons menuButtons;
private readonly MainFlowCoordinator mainFlowCoordinator;
private readonly TutorialFlowCoordinator tutorialFlowCoordinator;
private readonly MenuButton menuButton;
public MenuButtonManager(MenuButtons menuButtons, MainFlowCoordinator mainFlowCoordinator, TutorialFlowCoordinator tutorialFlowCoordinator)
{
this.menuButtons = menuButtons;
this.mainFlowCoordinator = mainFlowCoordinator;
this.tutorialFlowCoordinator = tutorialFlowCoordinator;
menuButton = new("Tutorial Mod", ShowFlowCoordinator);
}
public void Initialize()
{
menuButtons.RegisterButton(menuButton);
}
private void ShowFlowCoordinator()
{
mainFlowCoordinator.PresentFlowCoordinator(tutorialFlowCoordinator);
}
}
```
This seems more complex than it would be without Zenject, however, Zenject will call `Initialize` for
us on the first frame of the menu scene being loaded. Most importantly, this class is only responsible
for doing one thing: managing the menu button.
### Registering Custom Tags
If you have some custom UI tags that you want to use, it's recommended to bind them using Zenject. You
would bind them like this in a menu installer:
```c#
Container.Bind<BSMLTag>().To<MyCustomTag>().AsSingle();
Container.Bind<TypeHandler>().To<MyCustomHandler>().AsSingle();
```
## Affinity Patching
SiraUtil provides a way to make non-static [Harmony patches](./harmony-patching.md) using the "Affinity
API". Being able to make patch methods not static lets you make use of dependency injection for your
patches.
The syntax is mostly the same, however, Affinity is a lot more limited than Harmony. For the attributes, you must specify
a `AffinityPatch` attribute on every patch method, and you need to specify a patch type using either `AffinityPostfix`,
`AffinityPrefix`, or `AffinityTranspiler`. Do note - if you don't provide a patch type attribute then affinity will default
to a postfix.
### How To Affinity
Below is an example of an affinity patch taken from the SiraUtil documentation. It injects the `PauseController` and causes
the game to pause every 10 misses and cancels the miss by using a [prefix](./harmony-patching.md#prefix).
```c#
internal class PauseOnXMisses : IAffinity
{
private readonly PauseController pauseController;
public PauseOnXMisses(PauseController pauseController)
{
this.pauseController = pauseController;
}
private int misses = 0;
[AffinityPrefix]
[AffinityPatch(typeof(ScoreController), nameof(ScoreController.HandleNoteWasMissed))]
private bool HandleNoteWasMissedPrefix(NoteController noteController)
{
if (noteController.colorType == ColorType.None && misses++ < 10)
{
return true;
}
pauseController.Pause();
misses = 0;
return false;
}
}
```
As you can see, you just need to add the `IAffinity` interface to the patch class, then you need to bind it in a gameplay
related installer so that you have access to the `PauseController`.
```c#
Container.BindInterfacesTo<PauseOnXMisses>().AsSingle();
```
### Affinity's Limitations
Affinity is maintained separately from Harmony, so it doesn't have nearly as many features as Harmony does.
The main problem is the timing of the patch. Your patch will only be effective after the object graph is constructed,
so you can't patch `Awake` methods or constructors, for instance.
Secondly, your patches will be unapplied automatically when the DiContainer it was bound to is disposed, but this should
be fine in almost all cases.
## Custom Sabers
SiraUtil provides a unified way to replace the vanilla saber model, such that mods do not fight over which saber model
gets shown.
### Registering A Saber Model
Create a class which inherits from a `SaberModelController`, create the saber model registration, and bind it in a game
installer. You will have to provide a priority too so SiraUtil can decide which registration to use when there are
multiple.
```c#
internal class CustomSaberModelController : SaberModelController { }
```
```c#
var registration = SaberModelRegistration.Create<CustomSaberModelController>(0);
Container.BindInstance(registration).AsSingle();
```
### Additional Interfaces
`IColorable` will provide a property which receives a color when one is set by SiraUtil. This is primarily used by
Chroma to set the color of sabers to the color of Chroma-colored notes.
```c#
internal class CustomSaberModelController : SaberModelController, IColorable
{
public Color Color { get; set; } // Add behaviour on the setter
}
```
`IPreSaberModelInit` and `IPostSaberModelInit` provide methods which will be called before and after the `Init()`
method of the `SaberModelController` and also provide a reference to the original `Saber` and saber parent `Transform`.
The return type of `PreInit()` is `bool`, and it works just like Harmony prefixes; you should return `true` if you
want the original `Init` to run, otherwise return `false`.
```c#
internal class CustomSaberModelController
: SaberModelController, IPreSaberModelInit, IPostSaberModelInit
{
public bool PreInit(Transform parent, Saber saber) => true;
public void PostInit(Transform parent, Saber saber) { }
}
```
## Object Redecorating
Similarly to registering saber models, SiraUtil provides a way to modify the prefabs for various GameObjects before
they are bound in their installers.
As well as a priority, you can decide if it should be chained, which is useful if your redecoration doesn't causes
conflicts. SiraUtil will start at the registration with the highest priority, and if it has chaining, it will continue
to the next highest priority registration until it encounters a registration that doesn't have chaining.
The following example simply takes the `GameObject` of the `BombController` provided by the param of the `BombNoteRegistration`,
and adds a `CustomBombBehaviour` to it.
```c#
var bombNoteRegistration = new BombNoteRegistration(
redecorateCall: bomb =>
{
bomb.gameObject.AddComponent<CustomBombBehaviour>();
return bombNoteController;
},
priority: int.MaxValue,
chain: true);
Container.RegisterRedecorator(bombNoteRegistration);
```
Below is a collection of all possible redecorators provided by SiraUtil as of v3.1.14.
### Notes
| Name | Backing Prefab Type |
| --------------------------------- | ---------------------------------------------- |
| `BasicNoteRegistration` | `GameNoteController` |
| `ProModeNoteRegistration` | `GameNoteController` |
| `BurstSliderHeadNoteRegistration` | `GameNoteController` |
| `BombNoteRegistration` | `BombNoteRegistration` |
| `BurstSliderNoteRegistration` | `BurstSliderGameNoteController` |
| `LongSliderNoteRegistration` | `SliderController` |
| `MediumSliderNoteRegistration` | `SliderController` |
| `ShortSliderNoteRegistration` | `SliderController` |
| `ConnectedPlayerNoteRegistration` | `MultiplayerConnectedPlayerGameNoteController` |
### Debris
| Name | Backing Prefab Type |
| ----------------------------------------- | ------------------- |
| `NormalNoteDebrisHDRegistration` | `NoteDebris` |
| `NormalNoteDebrisLWRegistration` | `NoteDebris` |
| `BurstSliderHeadNoteDebrisHDRegistration` | `NoteDebris` |
| `BurstSliderHeadNoteDebrisLWRegistration` | `NoteDebris` |
| `BurstSliderElementNoteHDRegistration` | `NoteDebris` |
| `BurstSliderElementNoteLWRegistration` | `NoteDebris` |
### Multiplayer
| Name | Backing Prefab Type |
| ----------------------------------- | -------------------------------------- |
| `LocalActivePlayerRegistration` | `MultiplayerLocalActivePlayerFacade` |
| `LocalActivePlayerDuelRegistration` | `MultiplayerLocalActivePlayerFacade` |
| `ConnectedPlayerRegistration` | `MultiplayerConnectedPlayerFacade` |
| `ConnectedPlayerDuelRegistration` | `MultiplayerConnectedPlayerFacade` |
| `LobbyAvatarPlaceRegistration` | `MultiplayerLobbyAvatarPlace` |
| `LobbyAvatarRegistration` | `MultiplayerLobbyAvatarController` |
| `LocalInactivePlayerRegistration` | `MultiplayerLocalInactivePlayerFacade` |
+108
View File
@@ -0,0 +1,108 @@
---
prev: false
next: false
description: Learn how to create create mod configs for your Quest Mod!
---
# Quest Mod Configuration
Most mods require a configuration to allow users to change the functionality of the mod.
This section will guide you through the basics of using `config-utils` to create configuration for your mod.
## Prerequisites
- Install `config-utils` by running `qpm dependency add config-utils` in your project directory.
Make sure to restore after adding the dependencies.
## Declaring Your Configuration
First, you will need to define what your configuration will be. Create a `modconfig.hpp` header file, this will contain
the definition.
In `modconfig.hpp`, you should put the following:
```cpp
#pragma once
#include "config-utils/shared/config-utils.hpp"
// Declare the mod config as "ModConfiguration" and declare all its values and functions.
DECLARE_CONFIG(ModConfig,
// Declare "VariableA"
CONFIG_VALUE(VariableA, std::string, "Variable Name", "Variable Value");
)
```
Here is an example that uses all the types except `const char*` and `char*`
```cpp
#pragma once
#include "config-utils/shared/config-utils.hpp"
#include "UnityEngine/Color.hpp"
#include "UnityEngine/Vector2.hpp"
#include "UnityEngine/Vector3.hpp"
#include "UnityEngine/Vector4.hpp"
DECLARE_CONFIG(ModConfig,
CONFIG_VALUE(VariableString, std::string, "String Example", "Var Value");
CONFIG_VALUE(VariableInteger, int, "Integer Example", 5);
CONFIG_VALUE(VariableFloat, float, "Float Example", 1.5f);
CONFIG_VALUE(VariableBoolean, bool, "Bool Example", false);
CONFIG_VALUE(VariableDouble, double, "Double Example", 0.39221);
// dividing by 255 in color constructor because UnityEngine::Color represents RGBA as values in the range of 0 to 1
CONFIG_VALUE(VariableColor, UnityEngine::Color, "Color Example", UnityEngine::Color(10.0/255, 155.0/255, 90.0/255, 0));
CONFIG_VALUE(VariableVector2, UnityEngine::Vector2, "Vector2 Example", UnityEngine::Vector2(1, 2));
CONFIG_VALUE(VariableVector3, UnityEngine::Vector3, "Vector3 Example", UnityEngine::Vector3(1, 2, 3));
CONFIG_VALUE(VariableVector4, UnityEngine::Vector4, "Vector4 Example", UnityEngine::Vector4(1, 2, 3, 4));
)
```
## Loading your Config
Make sure to initialize the config! If you attempt to get values from it before it's loaded, your game will crash.
You can run this in `setup()`, `load()`, `late_load()`, or even anytime later if you really want to, but it only ever
needs to be run once.
```cpp
#include "modconfig.hpp"
// other code
extern "C" void late_load() {
// Initialize and load the config
getModConfig().Init(modInfo);
// other code.
}
```
## Using Your Configuration
In the following examples, we will be using the example that uses all the types
from [Declaring Your Configuration](#declaring-your-configuration)
```cpp
// Get VariableString
getModConfig().VariableString.GetValue();
// Set VariableString to "Eris cute"
getModConfig().VariableString.SetValue("Eris cute");
// Get VariableVector2 and store it as vec
UnityEngine::Vector2 vec = getModConfig().VariableVector2.GetValue();
// Add 30 to the x value.
vec = vec + UnityEngine::Vector2(30, 0, 0);
// Save VariableVector2 to the new vector
getModConfig().VariableVector2.SetValue(vec);
```
Setting a config variable will automatically save the configuration file.
The configuration file is usually stored at `~/ModData/com.beatgames.beatsaber/Configs/` on the Quest.
Your mod id will be used to create the configuration file, eg: `qosmetics.json`.
+338
View File
@@ -0,0 +1,338 @@
---
prev: false
next: false
description: Learn how to create C# macros for your Quest Mod!
---
# Quest Custom Types
`custom-types` is a library that allows you to create (fake) C# types using macros. These types can extend classes such
as `MonoBehaviour` and much more. `custom-types` also allows you to create [coroutines](https://docs.unity3d.com/Manual/Coroutines.html)
and [delegates](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/delegates/).
Custom Types are complex and requires knowledge of basic C#.
## Prerequisites
- Install `custom-types` by running `qpm dependency add custom-types` in your project directory.
Make sure to restore after adding the dependency.
## Basics
To create a custom type, create a header file for your type. In this example, we'll make a type called `Counter`
that extends `MonoBehavior`.
In your header file, include the macros file.
```cpp
#pragma once
#include "custom-types/shared/macros.hpp"
```
Since our `Counter` Custom Type will be extending `MonoBehaviour`, we need to include this too.
```cpp
#include "UnityEngine/MonoBehaviour.hpp"
```
### Declaring the Type
With those includes, we can now declare our `Counter` type. Types are declared using macros, similarly to hooking.
```cpp
// parameters are (namespace, class name, parent class, contents)
DECLARE_CLASS_CODEGEN(MyNamespace, Counter, UnityEngine::MonoBehaviour,
// DECLARE_INSTANCE_METHOD creates methods
DECLARE_INSTANCE_METHOD(void, Update);
// DECLARE_INSTANCE_FIELD creates fields
DECLARE_INSTANCE_FIELD(int, counts);
)
```
In C#, this would translate to the following:
```csharp
namespace MyNamespace
{
public class Counter : MonoBehaviour
{
public int counts;
public void Update()
{
}
}
}
```
Note that only basic types, such as `int`, `bool`, etc, and C# types can be used as instance
fields and method parameters declared with these macros. If you need something like a `std::vector`
or a c++ struct in your type, you can declare it after all the C# fields the same way you would
in a regular c++ struct or class.
### Defining the Type
Create a new source file - name it accordingly - and include your Custom Type header.
To define the type, use the `DEFINE_TYPE(Namespace, Class)` macro.
For our `Counter` type, this will look like so:
```cpp
#include "Counter.hpp"
DEFINE_TYPE(MyNamespace, Counter);
```
We can now define the methods that we have declared:
- `Update` - Unity's update method, declared by `DECLARE_INSTANCE_METHOD(void, Update);`
Our `Counter.cpp` file now looks like this:
```cpp
#include "Counter.hpp"
DEFINE_TYPE(MyNamespace, Counter);
// Unity update method - runs every frame this component is enabled
void MyNamespace::Counter::Update() {
// Add 5 to the counter field
counter = counter + 5;
}
```
## Overriding methods
We can also define methods that override those on parent types or interfaces, but we are limited to only overriding
methods explicitly defined as `virtual` or `abstract` in the C# code. For non interfaces, it's not always clear whether
this is the case for any given method if you don't have access to a decompiler and the PC game files, but an example of
a virtual method that is commonly overriden is `HMUI::ViewController::DidActivate`:
```cpp
// don't forget to include the types you use!
#include "HMUI/ViewController.hpp"
DECLARE_CLASS_CODEGEN(MyNamespace, CustomMenu, HMUI::ViewController,
// to override a method, we need the MethodInfo* of the original
// there are two common ways to get it, but unfortunately both of them make for relatively long lines
DECLARE_OVERRIDE_METHOD(void, DidActivate,
il2cpp_utils::il2cpp_type_check::MetadataGetter<&HMUI::ViewController::DidActivate>::get(),
bool firstActivation, bool addedToHierarchy, bool screenSystemEnabling);
// OR
DECLARE_OVERRIDE_METHOD(void, DidActivate,
il2cpp_utils::FindMethodUnsafe("HMUI", "ViewController", "DidActivate", 3),
bool firstActivation, bool addedToHierarchy, bool screenSystemEnabling);
// note that both of these seem to be calling methods at the global level, outside of any functions or hooks,
// that you normally cannot call until at least after load() --
// but actually, since these are macros, the code is actually moved inside of internal functions
// that get called at the correct times for registration
)
```
### Using Interfaces
Sometimes you will want to have your custom type inherit from interfaces. Putting them as the parent type will not work,
and instead there is a different macro for it:
```cpp
#include "HMUI/TableView_IDataSource.hpp"
// if there is no required parent class, Il2CppObject can be used to equal a plain object with no parent
// also, to inherit from multiple interfaces, they need to be wrapped with std::vector<Il2CppClass*>({ ... })
// to prevent the macro from expanding them incorrectly
DECLARE_CLASS_CODEGEN_INTERFACES(MyNamespace, TableData, Il2CppObject, { classof(HMUI::ISaberMovementData*) },
// rest of the custom type as normal
)
```
## Constructors
Some simple custom types do not necessarily need constructors, but there are a lot of cases where one does
need to be defined. You can create a fully custom one with the `DECLARE_CTOR` macro:
```cpp
DECLARE_CLASS_CODEGEN(MyNamespace, Counter, UnityEngine::MonoBehaviour,
// other members
// can have arguments the same as any other method
// but the return type is always void so it is omitted from the macro
DECLARE_CTOR(ctor);
)
```
And then define it just like any other method. However, in that definition, you should make sure to invoke the
constructor of the base class with `INVOKE_BASE_CTOR`:
```cpp
void MyNamespace::Counter::ctor() {
INVOKE_BASE_CTOR(classof(UnityEngine::MonoBehaviour*), ...constructor arguments);
// initialize other things
}
```
In the case of `MonoBehaviour`, this isn't necessary as it doesn't do anything in its constructor. If you inherit
other types, though, not invoking their constructors can cause hard to track down bugs.
Another case where the constructor would be used is if you use `DECLARE_INSTANCE_FIELD_DEFAULT` or have c++ style fields
in your class that need special initialization, such as `std::vector` or something with a default value, ex:
```cpp
DECLARE_CLASS_CODEGEN(MyNamespace, Counter, UnityEngine::MonoBehaviour,
// C# members
public:
int counts = 5;
)
```
In this case you define the constructor method the same way and include `INVOKE_CTOR()` in the method definition:
```cpp
void MyNamespace::Counter::ctor() {
// sets counts to 5
INVOKE_CTOR();
// initialize other things
}
```
If you want these macros but have nothing else to do in the constructor, you can skip the method definition and
just use `DECLARE_DEFAULT_CTOR`:
```cpp
DECLARE_CLASS_CODEGEN(MyNamespace, Counter, UnityEngine::MonoBehaviour,
// C# members
// invokes the MonoBehaviour constructor and sets counts to 5
DECLARE_DEFAULT_CTOR();
public:
int counts = 5;
)
```
Destructors can be defined custom similarly to contructors with `DECLARE_DTOR`, and/or `DECLARE_SIMPLE_DTOR` to run
the destructor for any c++ fields that need to have special behavior when being destroyed. You don't need to worry
about running the base class destructor, though.
::: warning
To create a new object, _do not_ run `ctor` yourself or create it in c++ with `new` or any similar operator,
but instead use `il2cpp_utils::New<MyNamespace::Counter*>(...constructor arguments);`, `Counter::New_ctor(...constructor
arguments);`, or any C# method that would
create an object, such as `AddComponent`.
:::
### Registering
You can register all the custom types you have created using the `custom_types::Register::AutoRegister()` method.
This method should be put in your `load()` or `late_load()` like so:
```cpp
#include "custom-types/shared/register.hpp"
// other code
extern "C" void late_load() {
// make sure this is after il2cpp_functions::Init()
custom_types::Register::AutoRegister();
// other code
}
```
To ensure correct behavior, make sure you install hooks _after_ you register your Custom Types!
### Using the Type
Custom Types can be used as if they were conventional C# types like you would find in the base game - for our `Counter` type,
we can add it as a component to a `GameObject` as it inherits `MonoBehaviour`.
```cpp
#include "UnityEngine/GameObject.hpp"
#include "Counter.hpp"
// in a hook somewhere
UnityEngine::GameObject* gameObject = UnityEngine::GameObject::New_ctor("CounterObject");
gameObject->AddComponent<MyNamespace::Counter*>();
```
## Coroutines
In Unity, a coroutine is a method that can pause execution and return control to Unity but then continue where it left
off on the following frame. [Unity Documentation](https://docs.unity3d.com/Manual/Coroutines.html)
### Creating a Coroutine
Using Custom Types, coroutines are pretty much the same as their C# counterparts. Take a look at this example:
```cpp
#include "custom-types/shared/coroutine.hpp"
#include "UnityEngine/WaitForSeconds.hpp"
#include "System/Collections/IEnumerator.hpp"
custom_types::Helpers::Coroutine counterCoroutine() {
int secondsPassed = 0;
// loop 30 times
for (int i = 0; i < 30; i++) {
secondsPassed++;
// wait one second
// arguments passed to co_yield must be cast to this type
// you can also use co_yield nullptr; to wait a single frame
co_yield reinterpret_cast<System::Collections::IEnumerator*>(UnityEngine::WaitForSeconds::New_ctor(1));
}
co_return;
}
```
| C# | C++ |
| -------------- | ----------- |
| `yield return` | `co_yield` |
| `yield` | `co_yield` |
| `yield break` | `co_return` |
`co_return` is used to end a coroutine. C# automatically handles this during compilation, but c++ does
not, so make sure you have one at the end of all your coroutines.
You can also use `co_return` to exit a coroutine early, just like `return` would in a typical function.
Using normal `return` in a coroutine will not work.
### Using the Coroutine
You can start a coroutine on any `MonoBehaviour` using the `StartCoroutine` method just like in C#, however
to create an actual coroutine from a function you need an extra call:
```cpp
#include "UnityEngine/GameObject.hpp"
#include "custom-types/shared/coroutine.hpp"
// in a hook somewhere
auto gameObject = UnityEngine::GameObject::New_ctor("MyCoroutineRunner");
// this is the example custom type we made earlier, but anything inheriting from a MonoBehaviour will work
auto myMonoBehaviour = gameObject->AddComponent<MyNamespace::Counter*>();
// create the object that we can pass to StartCoroutine from our function
auto coroutine = custom_types::Helpers::CoroutineHelper::New(counterCoroutine());
myMonoBehaviour->StartCoroutine(coroutine);
```
You can use `SharedCoroutineStarter` to start a coroutine without the need of an instance like so:
```cpp
#include "GlobalNamespace/SharedCoroutineStarter.hpp"
#include "custom-types/shared/coroutine.hpp"
// in a hook somewhere
auto coroutine = custom_types::Helpers::CoroutineHelper::New(counterCoroutine());
GlobalNamespace::SharedCoroutineStarter::get_instance()->StartCoroutine(coroutine);
```
## Other
Some extra information and recommended dos and don'ts can be found [here](https://github.com/sc2ad/Il2CppQuestTypePatching/wiki/FAQ).
+433
View File
@@ -0,0 +1,433 @@
---
prev: false
next: false
description: Learn how to create your own Quest mods!
---
# Quest Mod Development Intro
_Learn how to get started writing your own Quest Mods._
## Getting Started
::: warning
This guide is for making mods for the **Quest Standalone** version of Beat Saber!
If you use Oculus Link or similar, you want to visit the [PC Mod Development Guide](../pc/index.md) as that uses
the PC version of the game.
:::
This guide assumes you have a basic to intermediate understanding of the following:
- [C++](https://www.w3schools.com/CPP/default.asp)
- [CMake](https://cmake.org/cmake/help/latest/guide/tutorial/index.html)
- [ADB](https://developer.android.com/studio/command-line/adb)
- [Powershell](https://docs.microsoft.com/en-us/learn/modules/introduction-to-powershell/)
You may have difficulty understanding what is covered here if you do not have this foundation.
While this guide is for development on Windows, it is not dependent on an IDE. Instead you should configure your preferred
IDE accordingly by referring to the documentation. For example, you would need to install C++ tools for VSCode or configure
CMake for CLion.
## Environment Setup
The following pieces of software are needed to follow this guide.
- [Powershell](#powershell-core) - Cross Platform utility scripts
- [CMake](#cmake) - Build Automation
- [QPM](#qpm) - Dependency Management
- [Ninja](#ninja) - Build Tool
- [Android NDK](#android-ndk) - Native Development Kit for Android Devices
### Powershell Core
::: warning
You must download Powershell Core, the default windows Powershell will _not_ work.
:::
[Download the latest Powershell binary for your system](https://github.com/PowerShell/PowerShell/releases/latest) and add
it to your PATH variable, or
alternatively download and run the windows installer.
### CMake
[Download the latest CMake binary for your system](https://cmake.org/download/) and add it to your PATH variable, or
alternatively download and run the windows installer.
### QPM
[Download the latest QPM binary for your system](https://github.com/QuestPackageManager/QPM.CLI) from the
Actions tab, name it qpm.exe, and add it to your PATH variable, or alternatively download and run the Windows installer
from the appropriate workflow.
### Ninja
Download ninja via qpm using `qpm download ninja`.
Alternatively you can [Download the latest Ninja binary for your system](https://github.com/ninja-build/ninja/releases)
from the Releases tab
and add it to your PATH variable.
### Android NDK
Download the Andoid NDK via qpm using `qpm ndk download 27`, and add the extracted directory to a new environment variable
called ANDROID_NDK_HOME.
Alternatively you can run `qpm ndk pin 27` in a project directory to only apply the NDK in the current project.
If you wish you can instead download the NDK manually from the [Android NDK Downloads page](https://developer.android.com/ndk/downloads).
## Create a Project
Once you have setup your environment you can now generate a mod template. The template this guide uses is one by
[Lauriethefish](https://github.com/Lauriethefish/quest-mod-template). To start run the following command in Powershell.
```powershell
qpm templatr --git https://github.com/Lauriethefish/quest-mod-template.git <destination>
```
Templatr will then ask a series of questions to create a mod project.
![Templatr Example](/.assets/images/modding/quest-mod-template-example.png)
### Add and Update Dependencies
Once the project has been generated, you should now update the following two dependencies, [beatsaber-hook](https://github.com/QuestPackageManager/beatsaber-hook/)
and [bs-cordl](https://github.com/QuestPackageManager/bs-cordl), to the version best suited for the game version you are
developing for.
`beatsaber-hook` is a library that allows for modding il2cpp games. `bs-cordl` is a library that allows modders to
interface with the game's code.
To update these, open a Powershell terminal in the project directory then run the following commands to add the latest versions:
```powershell
qpm dependency add beatsaber-hook
qpm dependency add bs-cordl
```
If the latest versions do match those for the version you are developing for, add `-v ^x.x.x` after the command with the
correct version instead of running those commands. For example, for Beat Saber version 1.35.0, the correct codegen
version is 3500.0.0:
```powershell
qpm dependency add bs-cordl -v ^3500.0.0
```
### Restore Dependencies
Before you can open the project in an IDE, you must restore all of the dependencies. Consider this step similar to
fully initializing the project.
In a Powershell terminal in the project directory run:
```powershell
qpm restore
```
## Project Contents
Your project should contain the following structure:
```properties
// Files in .gitignore have been excluded
cmake/
└── ... project cmake files
extern/
└── ... dependencies should be here
include/
└── main.hpp
scripts/
└── ... utility scripts
shared
src/
└── main.cpp
.gitignore
CMakeLists.txt
mod.template.json
qpm.json
README.md
```
### Code Breakdown
#### src/main.cpp
`main.cpp` contains the `setup()` and `late_load()` methods. These methods can exist in any source file as long as they are
accessible by the modloader. Take a look inside of `main.cpp` for more information as Laurie has thankfully commented
most of the code.
#### shared
The shared folder can be exposed by QPM to other mods and published to the QPM dependency registry. Useful if you want
to make an API to let other mods control your mod in certain ways (for example Qosmetics has a model loading API).
Speak to @Sc2ad if you want to publish something.
#### extern
The extern folder should be ignored (and/or in some cases excluded). It contains dependencies, similarly to
`node_modules` (nodejs) or `packages` (.net core).
### Script Breakdown
It is recommended to run these scripts using Powershell Core (v7) - however, it is not required. All scripts can be run
with the `--help` argument for a description of arguments and functionality. Scripts can be manually invoked from the
`scripts` folder or via qpm scripts inside `qpm.json`
#### build.ps1
Usage: `qpm s build`
Builds your mod. Does not produce a QMOD file.
#### copy.ps1
Usage: `qpm s copy`
Builds your mod, then copies it to your quest and launches Beat Saber if your quest is connected with ADB.
#### createqmod.ps1
Usage: `qpm s qmod`
Generates a QMOD file that can be parsed by BMBF and or QuestPatcher. Will use the most recently built version of your mod.
#### pull-tombstone.ps1
Usage: `qpm s tomb`
Finds the most recently modified Beat Saber crash tombstone and copies it to your device. If the build on your quest matches
what you have most recently built locally, the `-analyze` argument can be provided to generate the source file locations
of any lines mentioned in the backtrace.
#### restart-game.ps1
Usage: `qpm s restart`
Closes and reopens Beat Saber on your quest if it is connected. Mostly used inside of `copy.ps1`. Does not have help text.
#### start-logging.ps1
Usage: `qpm s logcat`
Prints logs from Beat Saber, just your mod, or also crashes. Usage of `-self` is recommended.
#### validate-modjson.ps1
Usage: `qpm s validate`
Generates a `mod.json` from `mod.template.json` if not present and verifies it against the QMOD schema. Mostly used
inside of `createqmod.ps1`. Does not have help text.
## Hooking
Hooking is core to modding. `beatsaber-hook` provides a simple way of hooking methods and other miscellaneous stuff
like constructors.
> In computer programming, the term hooking covers a range of techniques used to alter or augment the behavior of an
> operating system, of applications, or of other software components by intercepting function calls or messages or events
> passed between software components. Code that handles such intercepted function calls, events or messages is called a hook.
> [Wikipedia](https://en.wikipedia.org/wiki/Hooking#:~:text=In%20computer%20programming%2C%20the%20term,events%20passed%20between%20software%20components.&text=Hooking%20can%20also%20be%20used%20by%20malicious%20code.)
To view a list of methods and classes you can hook, the most convenient option is to use a C# decompiler such as [IlSpy](https://github.com/icsharpcode/ILSpy)
if you own the game on PC, as it provides not only the classes and member names, but also the full contents of most methods.
If you only own the game on the Quest, then you can still view all the classes and methods in the `includes/codegen`
directory in your `extern` folder.
In this example, we will hook onto the initialization of the level screen and change the text on the play button to
something funny.
The level screen runs the event `DidActivate` when it is fully initialized. This is useful for us because we can hook
this event and add our own functionality.
Firstly, create your hook using the `MAKE_HOOK_MATCH` macro:
<!-- markdownlint-disable MD013 -->
```cpp
// You can think of these as C# - using HMUI, UnityEngine, etc, but with individual classes
// Classes without a namespace are assigned to the GlobalNamespace
// If you use a class and do not include it, you may get unclear compiler errors, so make sure to include what you use
#include "GlobalNamespace/StandardLevelDetailView.hpp"
#include "GlobalNamespace/StandardLevelDetailViewController.hpp"
#include "UnityEngine/UI/Button.hpp"
#include "UnityEngine/GameObject.hpp"
#include "HMUI/CurvedTextMeshPro.hpp"
// Create a hook struct named LevelUIHook
// targeting the method "StandardLevelDetailViewController::DidActivate", which takes the following arguments:
// bool firstActivation, bool addedToHierarchy, bool screenSystemEnabling
// and returns void.
// General format: MAKE_HOOK_MATCH(hook name, hooked method, method return type, method class pointer, arguments...) {
// HookName(self, arguments...);
// your code here
// }
MAKE_HOOK_MATCH(LevelUIHook, &GlobalNamespace::StandardLevelDetailViewController::DidActivate, void,
GlobalNamespace::StandardLevelDetailViewController* self, bool firstActivation, bool addedToHierarchy, bool screenSystemEnabling) {
// Run the original method before our code.
// Note that you can run the original method after our code or even in the middle
// if you want to change arguments or do something before it runs.
LevelUIHook(self, firstActivation, addedToHierarchy, screenSystemEnabling);
// Get the actionButton text object by accessing the actionButton field and some simple Unity methods.
// Note that auto can be used instead of declaring the full type in many cases.
GlobalNamespace::StandardLevelDetailView* standardLevelDetailView = self->_standardLevelDetailView;
UnityEngine::UI::Button* actionButton = standardLevelDetailView->actionButton;
UnityEngine::GameObject* gameObject = actionButton->get_gameObject();
HMUI::CurvedTextMeshPro* actionButtonText = gameObject->GetComponentInChildren<HMUI::CurvedTextMeshPro*>();
// Set the text to "Skill Issue"
actionButtonText->set_text("Skill Issue");
}
```
<!-- markdownlint-enable MD013 -->
Now, you have to install your hook. Usually, hooks are installed in `load()` or `late_load()` in `main.cpp`:
```cpp
MOD_EXTERN_FUNC void late_load() {
il2cpp_functions::Init();
PaperLogger.info("Installing hooks...");
INSTALL_HOOK(PaperLogger, LevelUIHook);
PaperLogger.info("Installed all hooks!");
}
```
You can now test to see if this was successful!
## Testing your Mod
### Without BMBF
You can test your mod without BMBF quickly using [`copy.ps1`](#copy-ps1). This is recommended while developing
for convenience. You should always test using a QMOD and BMBF if you're about to release your mod.
What[`copy.ps1`](#copy-ps1) does specifically is copy the `libmodname.so` in the `build` folder to the correct place on your
quest and then restart Beat Saber for you. You can also specify while launching to collect logs with the `-log` argument
followed by any of the arguments supported by the `start-logging.ps1` script:
```powershell
copy.ps1 -log -self -file latest.log
```
### With BMBF
Testing your mod with BMBF is useful to make sure BMBF shows and handles your QMOD correctly (copying files,
version, cover, etc.)
You will need to generate a QMOD file using [`createqmod.ps1`](#createqmod-ps1).
You can then upload the generated QMOD file to BMBF and it should install your mod - it should appear on the mods list.
You can still collect logs from your mod using the [`start-logging.ps1`](#start-logging-ps1) command after you launch
the game.
## Utilizing `mod.template.json`
`mod.template.json` contains basic information on your mod. It can also allow you to define other features such as:
- Cover Image (the preview image shown on the BMBF Mods tab)
- File Copies (extract files from the QMOD to a location on the quest device)
Some fields in it will be of the form `${x}` - those will be automatically filled by QPM based on the information in
your `qpm.json` and written to the file `mod.json`. It's not recommended to edit the `mod.json` manually, and it can be
updated at any time by running the command `qpm qmod build` (which only creates the `mod.json` file, not the QMOD itself.)
### Cover Image
A cover image is used by certain mods and BMBF to show a preview of your mod.
To add a cover image, simply name the image `cover.png`, put it in your project directory, and add the following to your
`mod.template.json`:
```json
"coverImage": "cover.png"
```
:::tip Cover Image Recommendations
- 1024x512 (BMBF will resize/crop the image to be this size)
- File format either png, jpg or gif
- Under 2mb to prevent load lag (larger images will take longer to show with no advantage)
:::
#### Example Cover Images
Click on the arrow beside the mod name to see the image.
<details><summary>
Noodle Extensions
</summary>
![Noodle Extensions](/.assets/images/modding/quest-ne-cover.jpg)
</details>
<details><summary>
Slice Details Quest
</summary>
![Slice Details Quest](/.assets/images/modding/quest-slice-details.jpg)
</details>
### File Copies
File copies is an array that can specify extra files in your QMOD to be copied to the quest, such as sabers included by
default in Qosmetics. You can add files by editing `createqmod.ps1` and `mod.template.json`.
#### Example
This example will add `secret-data.json` to the QMOD and copy it to `/sdcard/ModData/com.beatgames.beatsaber/Mods/Secret/secret-data.json`
Edit [createqmod.ps1](#createqmod-ps1) to include `secret-data.json`:
```powershell
# This is after line 59 of createqmod.ps1
$filelist += "/path/to/secret-data.json"
```
Update the following in your `mod.template.json`:
```json
"fileCopies": [
{
"name": "secret-data.json",
"destination": "/sdcard/ModData/com.beatgames.beatsaber/Mods/Secret/secret-data.json"
}
]
```
## Mod Configuration
Most mods require a configuration to allow users to change the functionality of the mod.
Visit the [Quest Mod Configuration](./config.md) page to learn the basics of using `config-utils` to create
a configuration for your mod.
## Custom Types
`custom-types` is a library that allows you to create the equivalent of C# types using macros. These types can extend
classes such as `MonoBehaviour` and much more. `custom-types` also allows you to create and use [coroutines](https://docs.unity3d.com/Manual/Coroutines.html)
and [delegates](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/delegates/).
Custom Types are complex and requires knowledge of basic C#. Visit the [Quest Custom Types](./custom-types.md)
page to learn more about integrating this into your mod.
## User Interface
A user interface (UI) is used by many mods to show configuration options. Visit the [Quest User Interface](./ui.md)
page to see how to use `bsml` to create a settings screen for your mod.
## Credits
Initial guide content was integrated from the Beat Saber Quest Modding Guide by [Calum](https://github.com/mineblock11)
with contributions from [Raine](https://github.com/raineio), [Pangwen](https://github.com/PangwenE), and [Metalit](https://github.com/Metalit/).
Integration and editing was done by [Bloodcloak](/about/staff.md#bloodcloak).
+81
View File
@@ -0,0 +1,81 @@
---
prev: false
next: false
description: Learn how to create a UI for your Quest Mod!
---
# Quest User Interface
:::warning
This is a stub page, content is a work in progress! Ask in `#quest-mod-dev` if you want more info!
:::
UI is used by many mods to show configuration options. In this section, we'll show you how to use `bsml` to create a
settings screen for your mod using code. `bsml` also supports creating UI with xml which can be found on the [BSML docs](https://redbrumbler.github.io/Quest-BSML-Docs/).
## Prerequisites
- Install `bsml` by running `qpm dependency add bsml` in your project directory.
- You also need to install `custom-types` even if you don't use it in your mod: `qpm dependency add custom-types`
Make sure to restore after adding the dependencies.
## Creating a `DidActivate` method
`DidActivate` is a method you can register with `bsml` that allows you to make a simple mod settings page.
Take a look at this example:
- You should only create your components on first activation to prevent duplication.
- You can utilize containers (such as Scrollable, HorizontalLayout and VerticalLayout) to manipulate the locations of components.
```cpp
#include "bsml/shared/BSML.hpp"
void DidActivate(HMUI::ViewController* self, bool firstActivation, bool addedToHierarchy, bool screenSystemEnabling) {
// Create our UI elements only when shown for the first time.
if(firstActivation) {
// Create a container that has a scroll bar
UnityEngine::GameObject* container = BSML::Lite::CreateScrollableSettingsContainer(self->get_transform());
// Create a text that says "Hello World!" and set the parent to the container.
BSML::Lite::CreateText(container->get_transform(), "Hello World!");
}
}
```
There are too many UI components and methods to document in this guide. However, the files in the `BSML-Lite/Creation`
folder have comments that document almost all the methods.
## Registering `DidActivate`
`bsml` contains a few locations you can register to:
- Main Menu Mod Tabs
![Main Menu Mod Tabs](/.assets/images/modding/quest-menu-mod-tab.png)
- Mod Settings
![Mod Settings](/.assets/images/modding/quest-mod-settings.jpg)
- Gameplay Setup
![Gameplay Setup](/.assets/images/modding/quest-gameplay-settings.jpg)
For `bsml` to use your `DidActivate` method, you will need to register it using the `BSML::Register` class in your
`late_load()` method.
```cpp
#include "bsml/shared/BSML.hpp"
// other code
extern "C" void late_load() {
// make sure this is after il2cpp_functions::Init()
BSML::Init();
BSML::Register::RegisterMainMenuViewControllerMethod(title, text, hoverHint, DidActivate);
// other code
}
```
The gameplay setup location requires a slightly different function signature than the other two, with the arguments
being just `UnityEngine::GameObject* self, bool firstActivation`.
All the register functions can be found in the `BSML.hpp` file.
+46
View File
@@ -0,0 +1,46 @@
# Notes
Gamedir
```sh
cd ~/.local/share/BSManager/BSInstances/1.40.8
```
## Logs
```sh
tail -f Logs/_latest.log
```
## FPFC Mode
Press `g` to release the mouse input from the game
Mouse input is broken because Wayland is not yet supported.
## Testing
To run beat saber, you can mimic the launch execution that bs-manager performs with the following:
```sh
cd "/home/pleb/.local/share/BSManager/BSInstances/1.40.8"
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/pleb/.local/share/Steam"
export STEAM_COMPAT_APP_ID=620980
export SteamEnv=1
export OXR_PARALLEL_VIEWS=1
steam-run "/home/pleb/.local/share/Steam/steamapps/common/Proton - Experimental/proton" run "$PWD/Beat Saber.exe" --no-yeet fpfc
```
Then, after a few seconds you can read the log messages after BS starts up
```
cat ~/.local/share/BSManager/BSInstances/1.40.8/Logs/_latest.log | grep Setlist
```
Thereafter, kill the process.
+420
View File
@@ -0,0 +1,420 @@
# 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)
- The BSMG wiki's [Modding](./modding/index.md) section is the workspace for reference.
+117
View File
@@ -0,0 +1,117 @@
For reference, the source code is in `~/src/rithik-b/PlaylistManager`.
Here is how “add this map to a playlist” is wired in this repo.
### 1. User clicks Add (opens the picker)
The Add button on the level detail screen calls `AddPlaylistModalController.ShowModal()`:
```64:70:PlaylistManager/UI/ViewControllers/LevelDetailButtonsViewController.cs
[UIAction("add-button-click")]
private void OpenAddModal()
{
addPlaylistController.ShowModal();
}
```
`ShowModal` parses the BSML if needed, opens the modal, and lists playlists from the default lib manager:
```97:103:PlaylistManager/UI/ViewControllers/AddPlaylistModalController.cs
internal void ShowModal()
{
Parse();
parserParams.EmitEvent("close-modal");
parserParams.EmitEvent("open-modal");
ShowPlaylistsForManager(PlaylistLibUtils.playlistManager);
}
```
### 2. User picks a playlist row — that is where the map is actually added
The real work is in `AddPlaylistModalController.OnCellSelect`: after navigating folders, choosing a playlist calls **`IPlaylist.Add(...)`** from **BeatSaberPlaylistsLib**, then persists and notifies:
```165:204:PlaylistManager/UI/ViewControllers/AddPlaylistModalController.cs
[UIAction("select-cell")]
private void OnCellSelect(TableView tableView, int index)
{
playlistTableData.TableView.ClearSelection();
// Folder Selected
if (index < childManagers.Count)
{
ShowPlaylistsForManager(childManagers[index]);
}
else
{
index -= childManagers.Count;
var selectedPlaylist = childPlaylists[index];
IPlaylistSong playlistSong;
if (HighlightDifficulty)
{
playlistSong = selectedPlaylist.Add(standardLevelDetailViewController.beatmapLevel, standardLevelDetailViewController.beatmapKey);
}
else
{
playlistSong = selectedPlaylist.Add(standardLevelDetailViewController.beatmapLevel);
}
try
{
selectedPlaylist.RaisePlaylistChanged();
parentManager.StorePlaylist(selectedPlaylist);
popupModalsController.ShowOkModal(modalTransform, string.Format("Song successfully added to {0}", selectedPlaylist.Title), null, animateParentCanvas: false);
// TODO: Doesn't refresh the sprite.
Events.RaisePlaylistSongAdded(playlistSong, selectedPlaylist);
}
catch (Exception e)
{
popupModalsController.ShowOkModal(modalTransform, "An error occured while adding song to playlist.", null, animateParentCanvas: false);
Plugin.Log.Critical(string.Format("An exception was thrown while adding a song to a playlist.\nException Message: {0}", e.Message));
}
finally
{
ShowPlaylistsForManager(parentManager);
}
}
}
```
So:
- **PlaylistManager code**: `AddPlaylistModalController.OnCellSelect` (orchestration, UI, `StorePlaylist`, `Events.RaisePlaylistSongAdded`).
- **Library code**: `selectedPlaylist.Add(...)` — implementation of how the entry is stored lives in **BeatSaberPlaylistsLib** (`IPlaylist`), not in this repository.
There is no other `.Add(` on a playlist for this flow in the grep results; removing a song is the parallel path in `LevelDetailButtonsViewController.RemoveSong()`.
## hooking into the process
### 1. Subscribe to PlaylistManagers public event (simplest)
After a successful add from the **Add to playlist** UI, PlaylistManager raises a **public static** event:
```16:18:PlaylistManager/Utilities/Events.cs
/// <summary>
/// Raised when an <see cref="BeatSaberPlaylistsLib.Types.IPlaylistSong"/> is added to an <see cref="BeatSaberPlaylistsLib.Types.IPlaylist"/>
/// </summary>
public static event Action<BeatSaberPlaylistsLib.Types.IPlaylistSong, BeatSaberPlaylistsLib.Types.IPlaylist> playlistSongAdded;
```
It is invoked **after** `RaisePlaylistChanged()` and `StorePlaylist()` succeed:
```187:194:PlaylistManager/UI/ViewControllers/AddPlaylistModalController.cs
try
{
selectedPlaylist.RaisePlaylistChanged();
parentManager.StorePlaylist(selectedPlaylist);
popupModalsController.ShowOkModal(modalTransform, string.Format("Song successfully added to {0}", selectedPlaylist.Title), null, animateParentCanvas: false);
// TODO: Doesn't refresh the sprite.
Events.RaisePlaylistSongAdded(playlistSong, selectedPlaylist);
}
```
In your plugin: add a **reference to `PlaylistManager.dll`**, a **manifest dependency** on PlaylistManager, then subscribe in `OnEnable` (or menu init) and unsubscribe in `OnDisable`:
- Namespace: `PlaylistManager.Utilities`
- Type: `Events`
- Event: `playlistSongAdded`
- Handler signature: `(IPlaylistSong song, IPlaylist playlist)` from `BeatSaberPlaylistsLib.Types`
**Caveat:** This is only raised for adds that go through **this** code path. It is the **only** `RaisePlaylistSongAdded` call site in the repo, so adds done only via BeatSaberPlaylistsLib (or another mod) will **not** fire this event.