Modules - Lakatrazz/BONELAB-Fusion GitHub Wiki
Code mods can become very complex and are unlikely to perfectly sync in Fusion. As a solution, Fusion provides a Module system that allows you to create your own messages, Gamemodes, and more.
Before getting started, make sure you understand how to create a MelonLoader mod.
Before you can add a module, make sure to reference LabFusion.dll
so that your MelonMod has access to all of its classes.
Once that is completed, add a class that inherits from LabFusion.SDK.Modules.Module
, like so:
using LabFusion.SDK.Modules;
public class MyModule : Module
{
public override string Name => "My Module";
public override string Author => "Author";
public override Version Version => new(1, 0, 0);
public override ConsoleColor Color => ConsoleColor.Red;
protected override void OnModuleRegistered()
{
}
protected override void OnModuleUnregistered()
{
}
}
To configure how your module appears, you can change the following settings:
- Name
- The name of the module.
- Author
- The author of the module (your name!)
- Version
- The version of the module.
- Color
- The color that the module will appear as in the console.
There are also two included virtual methods:
- OnModuleRegistered
- Invoked when the module is registered by your code. Here you should hook any necessary Fusion methods.
- OnModuleUnregistered
- Invoked when the module has been unregistered. Make sure to undo any changes your module has made here.
Now that you have a module, you can move onto loading it.
In your main MelonMod class, you can load the module like so:
public override void OnInitializeMelon()
{
if (FindMelon("LabFusion", "Lakatrazz") != null)
{
LoadModule();
}
}
private static void LoadModule()
{
LabFusion.SDK.Modules.ModuleManager.RegisterModule<MyModule>();
}
If Fusion is an optional dependency, it is important that you load the module in a separate method from your FindMelon check. The FindMelon check above makes sure that Fusion is loaded before attempting to load the module. If Fusion is missing and you include RegisterModule in the same method as the check, MelonLoader will throw a TypeLoadException.
LemonLoader does not support optional dependencies yet! If you want your mod to work on Android without Fusion, please use the embedded assembly approach below!
If your mod only has Fusion as an optional dependency and not required, you can add the following assembly attribute:
[assembly: MelonOptionalDependencies("LabFusion")]
This will make sure your mod loads fine without Fusion. Note that if you invoke Fusion code in your mod while Fusion isn't installed it will still error.
If you ever include a reference to a method that loads Fusion types (such as a module method or a message) inside of a method called while Fusion isn't installed, even if this method is never invoked the mod will error!
Because of this, it is recommended to instead use delegates that the module hooks into that replace the singleplayer version of your method with a multiplayer version. This can make it much easier to track and prevent accidental type load errors.
A safer alternative to optional dependencies is having your module be contained in a separate DLL file embedded into your mod. That way, you know for sure that all of your module code is separate and loaded only when Fusion is installed. However, it takes more effort to set up.
To embed an assembly (or any file), place the file somewhere within the project (I prefer to create a separate "resources" folder). Then, open the file's properties, and change "Build Action" to "Embedded resource", like so:
TIP: To remove the process of having to copy over your module DLL every time you build a new version, you can create a Post-build event that copies it for you, like so:
xcopy /y /d "$(ProjectDir)$(OutDir)$(AssemblyName).dll" "$(ProjectDir)..\MyResourceFolder"
Replace MyResourceFolder
with the path to where you will be storing the DLL. Now when you build your module, it will copy it to that folder!
To make loading embedded assemblies quicker, I have created a helper class that you can paste into your mod:
using System;
using System.IO;
using System.Linq;
using System.Reflection;
public static class EmbeddedResource
{
public static byte[] LoadBytesFromAssembly(Assembly assembly, string name)
{
string[] manifestResources = assembly.GetManifestResourceNames();
if (!manifestResources.Contains(name))
{
return null;
}
using Stream str = assembly.GetManifestResourceStream(name);
using MemoryStream memoryStream = new();
str.CopyTo(memoryStream);
return memoryStream.ToArray();
}
public static Assembly LoadAssemblyFromAssembly(Assembly assembly, string name)
{
var rawAssembly = LoadBytesFromAssembly(assembly, name);
if (rawAssembly == null)
{
return null;
}
return Assembly.Load(rawAssembly);
}
}
By calling LoadAssemblyFromAssembly
with the assembly of your mod and the path to the embedded assembly (ex. MyMod.resources.MyModule.dll
), you will load the embedded assembly from memory.
Before loading the module, lets create a ModuleLoader class in our module assembly:
using LabFusion.SDK.Modules;
namespace MyNamespace;
public static class ModuleLoader
{
public static void LoadModule()
{
ModuleManager.RegisterModule<MyModule>();
}
}
Finally, we can replace the old LoadModule method in OnInitializeMelon with reflection:
public override void OnInitializeMelon()
{
if (FindMelon("LabFusion", "Lakatrazz") != null)
{
EmbeddedResource.LoadAssemblyFromAssembly(Assembly.GetExecutingAssembly(), "MyMod.resources.MyModule.dll")
.GetType("MyNamespace.ModuleLoader")
.GetMethod("LoadModule")
.Invoke(null, null);
}
}
Now, the module is loaded entirely separate from your mod, and there will be no more dependency errors!
When you want to sync data, you'll need to send messages across the server. You can do this using the ModuleMessageHandler class.
To get started, create a new class that inherits from LabFusion.SDK.Modules.ModuleMessageHandler
. Then, implement the OnHandleMessage method:
using LabFusion.SDK.Modules;
using LabFusion.Network;
public class MyModuleMessage : ModuleMessageHandler
{
protected override void OnHandleMessage(ReceivedMessage received)
{
}
}
Next, we're going to register the message handler in our module. Go to your OnModuleRegistered override, and invoke LabFusion.SDK.Modules.ModuleMessageManager.RegisterHandler<T>()
:
protected override void OnModuleRegistered()
{
ModuleMessageManager.RegisterHandler<MyModuleMessage>();
}
Alternatively, you can register all handlers from an assembly using
LabFusion.SDK.Modules.ModuleMessageManager.LoadHandlers(Assembly assembly)
:
protected override void OnModuleRegistered()
{
ModuleMessageManager.LoadHandlers(System.Reflection.Assembly.GetExecutingAssembly());
}
Now that the message handler gets registered, we can start using it!
Before we can read/write data, we need to create a serializable class to hold it. To make bundling lots of data simple, Fusion provides an INetSerializable interface. Here is an example of an INetSerializable holding a string:
using LabFusion.Network.Serialization;
using LabFusion.Extensions;
public class MyNetSerializable : INetSerializable
{
public int? GetSize() => MyString.GetSize();
public string MyString;
public void Serialize(INetSerializer serializer)
{
serializer.SerializeValue(ref MyString);
}
}
The main things you want to look at are the Serialize(INetSerializer serializer)
and GetSize()
methods.
Serialize is required, as it handles reading/writing all of your data. In order to serialize a value, call serializer.SerializeValue(ref value)
, and it will automatically read/write the data type. This also works for custom INetSerializables. If you are in a situation where you need to know if the serializer is a reader or a writer, you can use the INetSerializer.IsReader
property.
GetSize, on the other hand, is optional, and only necessary when sending a lot of data, or a dynamic amount of data. You need to return an integer representing the amount of bytes that the message will send. If you return null or do not implement the method, a default amount of 4096 will be used, and excess bytes will be trimmed before being sent. Even if you are sending a small amount of fixed data, it is still recommended to implement this method for performance reasons.
Now that we have a custom INetSerializable, we can relay our message!
When relaying a module message, you are going to want to use the LabFusion.Network.MessageRelay
class:
var data = new MyNetSerializable() { MyString = "Hello!", };
MessageRelay.RelayModule<MyModuleMessage, MyNetSerializable>(data, new MessageRoute(RelayType.ToOtherClients, NetworkChannel.Reliable));
In the MessageRelay.RelayModule
, two generic parameters are required:
- TMessage:
- This is the type of the ModuleMessageHandler that you are sending.
- TData:
- This is the type of the INetSerializable data that you are sending.
Additionally, two input parameters are also required:
- Data
- This is the INetSerializable data that you are sending.
- Message Route
- This is the target that you are sending to.
The Message Route is important here, as it controls where the message will be sent and relayed to. It has a few important properties:
- Type
- ToServer - Sends directly to the host. Note that this requires the server to have the module installed, and is generally not recommended.
- ToClients - Sends to all clients, including the client sending the message.
- ToOtherClients - Sends to every client EXCEPT for the client sending the message.
- ToTarget - Sends to the Target player on the Message Route.
- ToTargets - Sends to every target in the Targets array on the Message Route.
- Channel
- Reliable - Almost guaranteed to be received, even under strained network conditions. Only use this for important one time data that NEEDS to be received.
- Unreliable - Packets will be dropped under strained network conditions. Use this for frequently updating information that can be dropped, like object position or visual effects.
- Target
- The target player SmallID if the Type is set to ToTarget.
- Targets
- Multiple target player SmallIDs if the Type is set to ToTargets.
Now that you are relaying your message, you can handle your logic in the message handler!
Now that the message has been sent, we need to read the sent data and run our own logic. First, move back to your ModuleMessageHandler's OnHandleMessage override. We're going to want to read the INetSerializable directly from the ReceivedMessage:
protected override void OnHandleMessage(ReceivedMessage received)
{
var data = received.ReadData<MyNetSerializable>();
var myString = data.MyString;
MelonLogger.Msg($"Received {value}!");
}
In the example above, since we wrote a string, we also read back a string. Make sure that you do not read more than was written, or else an error will be thrown.
In the ReceivedMessage struct, there are a few additional properties you can use for verification:
- Route
- The Message Route that the message was sent on.
- Sender
- The SmallID of the message's sender.
- Bytes
- The raw bytes of the message. It is recommended to use ReceivedMessage.ReadData to read this.
- IsServerHandled
- Whether or not the message was handled on the simulated "server". This is important for distinguishing messages sent to the server on the host's client from messages sent directly to the host client.
Now that your modules are registered, relayed, and handled, all of your logic should be ready! For any additional help, please DM lakatrazz on discord.