Bootstrap completed, hello world in BSIPA logs confirmed
This commit is contained in:
@@ -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 guide’s `../../../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 Steam’s live folder).
|
- Version pin: `1.40.8` (managed copy; launch modded build from BSManager, not Steam’s 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.
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")]
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 guide’s 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 install’s `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.
|
||||||
@@ -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!
|
||||||
@@ -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 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`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
## 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();
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

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

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

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

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

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

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

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

|
||||||
|
|
||||||
|
## 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);
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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).
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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    | 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)
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

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

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

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

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

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

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

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

|
||||||
|
|
||||||
|
Once you've opened the folder, navigate to
|
||||||
|
`Data\PlaybackEngines\windowsstandalonesupport\Variations\win64_player_development_mono`. The contents should look
|
||||||
|
like below.
|
||||||
|
|
||||||
|

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

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

|
||||||
|

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

|
||||||
|
|
||||||
|
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!
|
||||||
@@ -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` |
|
||||||
@@ -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`.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
</details>
|
||||||
|
<details><summary>
|
||||||
|
Slice Details Quest
|
||||||
|
</summary>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</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).
|
||||||
@@ -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
|
||||||
|

|
||||||
|
- Mod Settings
|
||||||
|

|
||||||
|
- Gameplay Setup
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user