Bootstrap completed, hello world in BSIPA logs confirmed

This commit is contained in:
pleb
2026-04-18 15:09:01 -07:00
parent 96b0e143ea
commit 752419121f
23 changed files with 3561 additions and 7 deletions
+9 -7
View File
@@ -3,12 +3,14 @@
## Repo ## Repo
- Modding workflow and references live in `docs/pc-modding.md` (Linux + Cursor + `dotnet` CLI; no VS/Rider BSMT extension). - Modding workflow and references live in `docs/pc-modding.md` (Linux + Cursor + `dotnet` CLI; no VS/Rider BSMT extension).
- `docs/pc-modding.md` §References (wiki paths, BSMT templates, BSIPA, SongCore, etc.) match 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) ## Game install (BSManager)
- **Path:** `/home/pleb/.local/share/BSManager/BSInstances/1.40.8` - 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). - Version pin: `1.40.8` (managed copy; launch modded build from BSManager, not Steams live folder).
- **BSIPA:** Present (`IPA/`, `IPA.exe`, `winhttp.dll`, `Plugins/`). - BSIPA: Present (`IPA/`, `IPA.exe`, `winhttp.dll`, `Plugins/`).
## Plugins currently in `Plugins/` ## Plugins currently in `Plugins/`
@@ -16,11 +18,11 @@ BeatSaverDownloader, BeatSaverUpdater, BSML, BS_Utils, PlaylistManager, SiraUtil
## Host toolchain ## Host toolchain
- **dotnet:** `9.0.312` (SDK 6+ is fine for `net472` plugin builds per guide). - 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). - ilspycmd: `9.1.0.0` (decompile/reference game or plugin assemblies from CLI).
- **NuGet:** Installed (per user setup). - NuGet: Installed (per user setup).
## Conventions agents should respect ## 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. - 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. - Point `BeatSaberDir` / game references at the BSManager instance path above when editing project user files or HintPaths.
+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>
+34
View File
@@ -0,0 +1,34 @@
using IPA;
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; }
[Init]
public Plugin(IPALogger logger)
{
Instance = this;
Log = logger;
}
[OnStart]
public void OnApplicationStart()
{
Log.Info("Hello World");
}
[OnExit]
public void OnApplicationQuit()
{
}
}
}
+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.0.1.0")]
[assembly: AssemblyFileVersion("0.0.1.0")]
+123
View File
@@ -0,0 +1,123 @@
<?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>
<TargetFrameworkVersion>v4.7.2</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="Main">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Main.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.VRModule">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.VRModule.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="Plugin.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.net472">
<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>
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://raw.githubusercontent.com/bsmg/BSIPA-MetadataFileSchema/master/Schema.json",
"id": "Setlist",
"name": "Setlist",
"author": "",
"version": "0.0.1",
"description": "Syncs playlists with external sources.",
"gameVersion": "1.40.8",
"dependsOn": {
"BSIPA": "^4.3.0"
}
}
+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.
+1
View File
@@ -417,3 +417,4 @@ decompiled/
- BSManager (recommended Linux installer): <https://github.com/Zagrios/bs-manager> - 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) - 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) - 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.