Bootstrap completed, hello world in BSIPA logs confirmed
This commit is contained in:
@@ -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` |
|
||||
Reference in New Issue
Block a user