3.1 InterModComm by Zat - LionShield/Kingdoms-and-Castles-Toolkit GitHub Wiki

InterModComm

The InterModComm (IMC) framework provides you with tools to communicate between mods. To accomplish this, IMC uses "ports" (IMCPort) that send and receive "messages" (IMCMessage). Based on the ability to send and receive messages IMC implements remote-procedure-calls (RPC) functionality. Using RPCs, your mod can call methods of another mod, optionally passing and receiving complex data structures.

IMC can be used to implement full-duplex request-response communication in a P2P or client-server architecture.

Since there is no discovery or broadcasting mechanism implemented yet in IMC, and IMCPort needs to know the name of the foreign port it wants to communicate with. The ModMenu solves this issue by providing one single IMCPort with a static and well-known name. The mods interfacing with the ModMenu then just send messages to this well-known port.

How it works

Since mods can't share custom-made C# assemblies and thus can't share custom data-structures (i.e. classes or structs), IMC uses JsonConvert and SendMessage under the hood to (de-)serialize and transport data between IMCPorts.

SendMessage calls a method on an object by name - IMCPorts implement a private "ReceiveMessage" method for this purpose. It's counterpart is "SendIMCMessage". Both methods use IMCMessages, which represent either requests to or responses from IMCPorts. IMCMessages can have payloads which are used as method parameters and return values for requests and responses, respectively.

When a request-message is sent through an IMCPort A, the port adds the message's ID to a list of pending responsenses. When the other IMCPort B received the message it may respond. It can respond by sending a response-message with a matching ID to A. This way, A knows that B received and processed the request.

Most importantly, the SendMessage-related methods are not intended to be used by mod-authors directly. Instead, the RPC methods should be used.

Getting started

This is rather simple:

  1. Copy Zat.InterModComm.cs into your mod's directory.
  2. Add a using-statement that references IMC: using Zat.Shared.InterModComm;
  3. Somewhere in your mod logic, add an IMCPort to a custom, uniquely named GameObject: var port = gameObject.AddComponent<IMCPort>()
  4. Set-up your RPC-listeners using RegisterReceiveListener
  5. Call RPC-listeners using RPC

RPC system

The RPC implementation is a fancy wrapper around the "low-level" ReceiveMessage/SendIMCMessage methods. It provides the "RegisterReceiveListener" and "RPC" methods to process incoming calls and issue outgoing calls, respectively.

RegisterReceiveListener

The RegisterReceiveListener method allows registering callbacks for individual "functions" (which are unique strings). The callback will have a reference to an IRequestHandler and the name of the sender (source). The handler is used to optionally send responses (using SendResponse() or SendResponse<T>(T value)). Sending empty responses will let the sender know that the request was processed. Sending responses with a value will return this value to the sender. One can choose not to send any response, in which case the request will time-out on the sender's side.

Example: If you wanted to implement a simple ping/pong mechanism where mod A calls a "ping" method of mod B and mod B responds to it, mod B could register a listener for a "ping" method like this:

public void Start() {
    //...
    this.port.RegisterReceiveListener("ping", PongHandler);
    //...
}

private void PongHandler(IRequestHandler handler, string source) {
    Debug.Log($"Received ping from {source}!");
    handler.SendResponse(); //Respond back to mod A
}

RPC

The RPC method calls functions of another IMCPort. It requires the name of the foreign IMCPort, the name of the function to call, optional method-parameters to pass along, a time-out duration in which a response is expected and callbacks for successfull and failed execution.

Example: If you wanted to call the function Random of mod B, mod A could call it like:

private class RandRequest {
    public int Min { get; set; }
    public int Max { get; set; }
}
private class RandResponse {
    public int Value { get; set; }
}
public void Update() {
    //...
    var request = new RandRequest() { Min = 0, Max = 10 };
    this.port.RPC<RandRequest, RandResponse>("modB", "Random", request, 1f, (response) => {
        Debug.Log($"modB.Random returned {response.Value}!");
    }, (exception) => {
        Debug.Log($"Failed to call modB.Random: {exception.Message}");
    });
    //...
}

In order for this example to work, both mod A and mod B need to share the same data-structures. As of now, this can only be accomplished by copy-pasting them across mods. So for the above example to work, mod B needed to implement the following:

private class RandRequest {
    public int Min { get; set; }
    public int Max { get; set; }
}
private class RandResponse {
    public int Value { get; set; }
}
public void Start() {
    //...
    this.port.RegisterReceiveListener<RandRequest>("Random", (handler, source, request) => {
        var result = UnityEngine.Random.Range(request.Min, request.Max);
        Debug.Log($"{source} called Random with Min={request.Min} and Max={request.Max}, responding {result}!");
        handler.SendResponse(this.port.gameObject.Name, new RandResponse() { Value = result });
    });
    //...
}

Notes

When using IMC, please have the following notes in mind:

  1. Method parameters/return types: You can optionally specify whether you want to pass along method-parameters and what type of return value you expect (if any). You can use any mix of those, however you must ensure that both mods assume the same mix of parameters and return types.
  2. Custom data-structures: When using custom data-structures, both mods need to ship with their own, separate definitions of those types.
  3. Fire & forget: The RPC functions don't block and will just run your callbacks whenever they receive a response. There's no way to cancel requests.
  4. Timeouts: It's perfectly reasonable not to respond to requests. However, when you do so, the sender will throw a TimeoutException.
  5. Naming: IMC requires the GameObjects your IMCPorts are attached to to have unique names. It uses the GameObjects' names to send it messages.
  6. Singular ports: There can always only be one IMCPort attached to an individual GameObject.
  7. Race conditions: Since mod load-order is out of your control and since you can't say whether another mod has been loaded yet, it may happen that a target IMCPort is not initialized yet when you try to send messages to it. For this, you need to implement retries and delays into your "client".

Troubleshooting

The IMC framework performs a lot of sanity-checks at runtime and will let you know when you do something that you shouldn't do (e.g. registering multiple listeners for the same method). Thus, you should take a look at your mod's logs (output.txt), IMC will post error messages there.

Due to the asynchronous and distributed nature of IMC, debugging can be quite the pain. You can try using Unity Explorer to debug your code.

⚠️ **GitHub.com Fallback** ⚠️