lstwoMODS Core • Overlay Plugins

Custom Messages

Defining a Message

Element state is the right channel for widget values. For anything else (a database the overlay should load, a command the game should run, a snapshot of live game state) use a custom IPC message.

Message classes live in SharedObjects so both processes can bind to them:

using lstwoMODS.ImGui.Shared;
using Newtonsoft.Json;

namespace MyMod.SharedObjects
{
    public class SpawnPropMessage
    {
        public const string MessageType = "MyMod.SpawnProp";

        public string Address   { get; set; }
        public bool   Networked { get; set; }

        public IpcMessage Serialize() => new IpcMessage
        {
            Type = MessageType,
            Payload = JsonConvert.SerializeObject(this)
        };

        public static SpawnPropMessage Deserialize(IpcMessage msg)
            => JsonConvert.DeserializeObject<SpawnPropMessage>(msg.Payload);
    }
}

Prefix MessageType with your mod name. The overlay routes by string, and a collision with a core message type (FrameStateMessage, KeyPressMessage, WindowInitMessage, …) means your handler never gets reached.

Payloads use plain JsonConvert without type name handling, so keep them simple data objects. Polymorphism isn’t available here, that’s only for the element tree. Put [JsonConverter(typeof(StringEnumConverter))] on enums so payloads stay readable and survive reordering.

Mod to Overlay

Sending is just a channel call:

UIManager.IpcChannel.SendMessage(new SpawnPropMessage
{
    Address   = "props/chair",
    Networked = true
}.Serialize());

And receiving happens in IOverlayPlugin.Initialize:

ctx.RegisterMessageHandler(SpawnPropMessage.MessageType, msg =>
{
    var spawn = SpawnPropMessage.Deserialize(msg);
    if (spawn != null) MyState.QueueSpawn(spawn);
});

Any message type the overlay doesn’t recognise falls through to the registered plugin handlers, so nothing else has to be wired up. There is one handler per type, registering the same type twice replaces the previous handler.

Overlay to Mod

The overlay side wraps outgoing plugin messages in an envelope with the type _plugin, so send the serialized inner message as the payload:

internal static void SendCommand(PropGroupCommandMessage command)
    => Context.SendToMod(JsonConvert.SerializeObject(command.Serialize()));

SendToMod is thread safe and can be called from the render thread.

On the mod side there is no registry, so subscribe to the channel directly and unwrap it yourself:

public static void Initialize()
{
    UIManager.IpcChannel.MessageReceived += msg =>
    {
        Handle(msg);
        return Task.CompletedTask;
    };
}

// Runs on the IPC reader thread.
private static void Handle(IpcMessage msg)
{
    if (msg.Type != "_plugin") return;

    try
    {
        var inner = JsonConvert.DeserializeObject<IpcMessage>(msg.Payload);
        if (inner == null) return;

        if (inner.Type == SpawnPropMessage.MessageType)
        {
            var spawn = SpawnPropMessage.Deserialize(inner);
            MainThread.Enqueue(() => PropSpawnManager.SpawnFromLibrary(spawn));
        }
        else if (inner.Type == PropGroupCommandMessage.MessageType)
        {
            // An unknown enum value from a mismatched overlay build throws here and is
            // swallowed below, which is the behaviour we want for version skew.
            var command = PropGroupCommandMessage.Deserialize(inner);
            MainThread.Enqueue(() => Execute(command));
        }
    }
    catch { }
}

Hook that up from UIManager.OnInitialized so it runs once the channel exists.

Threading

This is the part that breaks plugins.

On the overlay side, RegisterMessageHandler callbacks run on the IPC reader task, not on the render thread. Renderers walk their collections during Render(), so mutating them from a handler produces “Collection was modified” crashes mid frame. Queue in the handler, then swap the value in at the top of Render():

private PropGroupsStateMessage? _pendingSnapshot;

// IPC thread
public void QueueSnapshot(PropGroupsStateMessage msg)
    => Interlocked.Exchange(ref _pendingSnapshot, msg);

// render thread, first thing in Render()
public void ConsumePending()
{
    var snapshot = Interlocked.Exchange(ref _pendingSnapshot, null);
    if (snapshot != null) ApplySnapshot(snapshot);
}

Interlocked.Exchange is enough for a latest wins snapshot. Use a lock or a concurrent queue when every single message has to be processed.

On the mod side the same applies for Unity. Handlers run on the IPC reader thread, where Unity API calls are illegal, so wrap them in MainThread.Enqueue(...), which runs them during Plugin.Update(). The same hazard applies to element callbacks and Ref<T>.Changed handlers.

Size and Rate

A single IPC frame is capped at 32 MB, so cap anything unbounded before you send it. The prop spawner truncates its snapshot at 2000 live props for exactly that reason.

Only send when something actually changed. The usual shape is a dirty flag drained by a coroutine on the mod side:

public static void MarkDirty() => _snapshotPending = true;

private static IEnumerator PumpRoutine()
{
    while (true)
    {
        if (_snapshotPending && UIManager.IpcChannel != null)
        {
            _snapshotPending = false;
            SendSnapshot();
        }
        yield return null;
    }
}

On the overlay side, compare a revision integer before rebuilding filtered views, so a snapshot that didn’t change costs nothing.

Startup Order and Reconnects

Neither side can assume the other is ready.

The overlay process starts after the mod, and UIManager.IpcChannel is null until it does, so guard every send with a null check or hook UIManager.OnInitialized. The overlay also gets restarted automatically if it crashes (up to a limit), and while the mod side replays its window trees, your plugin’s state is gone.

The fix is a handshake where the newly initialized side asks for everything:

// overlay side
ctx.RegisterMessageHandler(PropDatabaseReadyMessage.MessageType, msg =>
{
    var ready = PropDatabaseReadyMessage.Deserialize(msg);
    if (ready?.CachePath == null) return;

    PropSpawner.LoadDatabase(ready.CachePath);

    // The database swap invalidates back-references, and this is also the point at which an
    // overlay that started late needs the live picture.
    SendCommand(new PropGroupCommandMessage { Command = PropGroupCommand.Refresh });
});

The mod then resends its ready message whenever the channel comes up. Treat every message as possibly arriving twice and every state message as a full snapshot rather than a delta, and reconnects stop being a special case.