Skip to content

LaunchPadBooster Networking

LaunchPadBooster's networking layer (LaunchPadBooster.Networking) exposes the mod-to-mod wire-format primitives that every StationeersPlus mod uses to send custom messages between host and clients. It wraps vanilla Stationeers networking with automatic compression, multi-packet splitting, and a mod-version handshake.

Primitives

LaunchPadBooster.Networking.IModNetworking exposes: - bool Required { get; set; }: mod-version handshake rejects clients without matching install. - void RegisterMessage<T>() where T : INetworkMessage, new().

There are NO public connect/disconnect events; Harmony-patch NetworkManager.PlayerConnected as the documented workaround.

Mod.SetMultiplayerRequired() exists but is [Obsolete(error: true)]. Use MOD.Networking.Required = true instead.

INetworkMessage extension methods (in LaunchPadBooster.Networking.ModNetworkingExtensions): - void SendToHost(): client -> server - void SendToClient(Client client): server -> specific client - void SendDirect(long connectionId, ConnectionMethod method): low-level - void SendAll(long excludeConnectionId): server -> all clients. Pass 0L for "no exclusion"; there is no zero-arg overload.

INetworkMessage.Process(long hostId): hostId is the connection ID of the peer who sent the message. On a client receiving a host broadcast, hostId = host's connection ID.

Where Process's id comes from, and why a vanilla message has none

Re-read at game 0.2.6428.27798 against the installed LaunchPadBooster.dll (AssemblyVersion("0.2.0.0"), file version 0.2.0; the client and dedicated-server installs carry byte-identical copies). The line above holds on both sides: Process is handed the id of the connection the packet arrived on. LaunchPadBooster line numbers are .work/decomp/0.2.6428.27798/LaunchPadBooster.decompiled.cs; game line numbers are the client Assembly-CSharp.decompiled.cs of the same folder, and every game method cited is textually identical in the dedicated-server build (diffed; ReceiveEvents is at server 265580).

The id is the packet's sender, all the way down.

  1. Vanilla NetworkManager.ReceiveEvents (279187) reads each packet into three locals: the channel, the payload size and the sender id, which is (long)p2Packet.Value.SteamId.Value for a Steam P2P packet (279205) and (long)ptr->Guid.G, the RakNet GUID, for a RakNet packet (279260). The method's IL declares them as .locals init ([0] bool, [1] int32, [2] int32, [3] int64, ...), and the IL stores the channel byte into local 1, the size into local 2 and the sender id into local 3. Read with ilspycmd -il on both installs' Assembly-CSharp.dll; the two methods' IL is identical.
  2. LaunchPadBooster's transpiler on ReceiveEvents (TranspileReceiveEvents, 1106-1153) finds the newobj ArgumentOutOfRangeException / throw pair at the end of the channel switch, removes it, and inserts ldloc.1, ldloc.2, ldloc.3, call ReceiveUnknownEvent(int, int, long), ret. So channel 169 reaches ReceiveUnknownEvent(int channel, int size, long connectionId) (1943-1952) with local 3 as connectionId.
  3. ReceivePacket (1623-1628) hands the packet to GetConnection(connectionID) (1513-1521), a dictionary lookup keyed by the id that AddConnection (1531-1535) registered from LaunchPadBooster's postfix on NetworkManager.PlayerConnected (1162-1168). Vanilla calls PlayerConnected with that same local 3 (279284 for an accepted Steam P2P connection, 279329 for an incoming RakNet connection, 279335 for an accepted RakNet request) and, for a Steam P2P request a server accepts, with (long)steamId.Value (278859). An id with no entry gets a new ConnectionState and the warning Received data on unregistered connection {connectionID}.
  4. ConnectionState.ReceiveMessage (2720-2750) drops the message unless Status == ConnectionStatus.Ready, which is set in one place, when the join validation passes (2445), and otherwise calls networkMessage.Process(ConnectionID) (2744). ConnectionID is the readonly field (2216) set in the constructor to the dictionary key.

The chain is synchronous: CompleteReceive (2386) dispatches a PacketType.Message straight to ReceiveMessage, inside the ReceiveEvents loop, so a mod message's Process runs on the Unity main thread like a vanilla one (see NetworkManager, "Inbound messages are processed on the Unity main thread, on both builds").

On a server that id is the one the sender's Client record carries. NetworkServer.ClientConnected(long connectionId, ...) (219385-219394) sends the new connection a VerifyPlayerRequest with ClientConnectionID = connectionId, the joiner echoes it back as VerifyPlayer.OwnerConnectionId, and NetworkServer.VerifyConnection (219435-219463) builds the joiner's Client with that echo as its connectionId. For an honest joiner, then, Client.Find(long) resolves a mod message's id to the sender's Client. The record is built from what the joiner sent, not from the id the packet arrived on; see Client, "From a connection to a player's character".

With LaunchPadBooster loaded, the server's join state follows the echo too. ReadJoinHeaderServer (1205-1210) reads the join-validate header into GetConnection(__instance.OwnerConnectionId), and the transpiled VerifyPlayer.Process (TranspileVerifyPlayer, 1212-1223) calls ReceiveVerifyPlayer (1733-1736), which stores the message on GetConnection(message.OwnerConnectionId). When the validation passes, the server-side branch calls NetworkServer.VerifyConnection(ModNetworking.GetHostId(), VerifyPlayer) (2454). For an honest joiner the echoed id and the transport id are the same number, so all of this lands on one ConnectionState.

The interface names the parameter clientId. INetworkMessage declares void Process(long clientId); (3577). The name belongs to the declaration only; an implementation names it as it likes (Spray Paint Plus uses hostId). The value is a connection id, the thing Client.connectionId holds: look it up with Client.Find(long), and never cast it to ulong for Client.Find(ulong), which is keyed on ClientId. Over Steam P2P a connection id is the peer's SteamID and so often has the same numeric value as its ClientId (PlayerIdentityAndSteam), which hides a wrong overload until the first non-Steam join.

A vanilla message on a server does not know its sender. Vanilla dispatch does not pass local 3 on. The channel switch in ReceiveEvents sends both message channels to HandleGeneralTraffic with a static field instead:

// Assets.Scripts.Networking.NetworkManager :: ReceiveEvents (channel switch)
// .work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs:279305-279307 (server build :265698-265700)

case NetworkChannel.GeneralTraffic:
case NetworkChannel.Unreliable:
    HandleGeneralTraffic(_hostId, num2);

HandleGeneralTraffic (279346-279356) passes it to NetworkBase.DeserializeReceivedData (39291-39312), its only caller, which ends in messageProcessable.Process(hostId) (39310). _hostId is declared private static long _hostId = -1L; (278280), reset to -1L in EndConnection (278391), and written only on paths a client takes: StartClient(SteamId) (279155), the Steam P2P SteamP2PConnectionAccepted case, which sits inside if (NetworkRole == NetworkRole.Client) (279282), and RakNet's ConnectionRequestAccepted (279334), which only the side that called rakNet.Connect receives (StartClient(string, ushort, ushort), 279125). So on a listen host and on a dedicated server the hostId of every vanilla ProcessedMessage is -1, and a vanilla handler that needs to know who sent a message can only trust something in its body: AttackWithMessage.Process (283200-283211) acts for Thing.Find(AttackParentId) with no check of who sent it (see GameMessageFactory). LaunchPadBooster reads the same field through ModNetworking.GetHostId, a compiled accessor for it (1444): SendToHost (3629-3633) sends to it, a client reads the host's join-validate header under it (ReadJoinHeaderClient, 1198-1203), and the server passes it as VerifyConnection's first argument (2454), which the Client constructor ignores. On the server build the same lines are _hostId 264673, 264784, 265548, 265675 and 265727, with the dispatch at 265700.

Handshake / version

MOD.Networking.Required = true tells LaunchPadBooster to reject connections from clients that do not have the mod, or have a different version. This ensures all players run the same wire format.

The Required gate is SYMMETRIC, and a mod the SERVER has and the client does not is equally fatal

ConnectionState.DoJoinValidateMod (LaunchPadBooster.decompiled.cs:2095-2172) compares the two mod lists in both directions and rejects on either. It builds a HashSet of the incoming side's mod hashes, then:

  • for each incoming entry with no local ModNetworking.InstancesByHash match, if item.Required it goes on the "this side is missing it" list;
  • for each local ModNetworking.Instances entry whose hash is absent from the incoming set, if instance.Required it goes on the "the other side is missing it" list;
  • matched pairs additionally run VersionValidator.ValidateVersion, and a validator that THROWS is counted as a failure rather than ignored.

If all four lists are empty it returns true. Otherwise it labels the sides from NetworkManager.IsServer and builds one line per problem:

stringBuilder.AppendLine($"{text} missing mod {text3}@{text4} (hash {num})");

then calls CloseWithError, which on the client reaches FailClientJoin (:1399-1405):

ConsoleWindow.PrintError(message, suppressStacktrace: true);
NetworkClient.StopConnectionTimer();
NetworkManager.EndConnection();
ShowConfirmationPanel(Singleton<ConfirmationPanel>.Instance, "Incompatible mods", message, "ButtonOk", NetworkClient.Cancel);

So the rejection is loud, immediate and specific, which is the opposite of the vanilla connection-failure behaviour documented on Connection failures are silent. It arrives as a modal titled Incompatible mods, and it is raised on the joining client even when the mod the joiner lacks is one only the SERVER loaded.

Measured, 2026-08-11, on the client rig: a listen host with StationeersLua@0.9.5.0 and ScriptedScreens@0.9.5.0 enabled, against a joiner with both disabled, produced

Incompatible mods
client missing mod StationeersLua@0.9.5.0 (hash 210483820)
client missing mod ScriptedScreens@0.9.5.0 (hash -69930857)

on 5 of 5 attempts, each failing in 3.9 to 4.0 seconds. Both mods disabled on both sides: 5 of 5 joined, 11.8 to 15.3 s. Both mods ENABLED on both sides: 5 of 5 joined, 12.2 to 14.9 s. The variable is whether the two mod lists MATCH, not whether the mods are loaded.

Two consequences worth stating plainly:

  • A Workshop mod nobody wrote a line of networking code for can still be Required. Neither of the two above is a networking mod in any obvious sense. Any mod that sets the flag participates in this gate, so "the host has one extra mod" is a complete explanation for a join that never happens.
  • The failure is fast and named, so it is easy to tell apart from a silent RakNet failure. A join that dies in about 4 s with a modal is this gate; a join that hangs to a driver timeout with nothing logged is the vanilla silence.

Networking V2 benefits over vanilla piggybacking

Moved from piggybacking on ThingColorMessage to LaunchPadBooster's dedicated message channels. V2 provides automatic compression, multi-packet splitting, and a version handshake. The handshake rejects mismatched mod versions, preventing wire-format desync.

No public PlayerConnected event

Host code that needs to push initial state to a newly joined client must Harmony-postfix NetworkManager.PlayerConnected. From DistanceConfigSync.cs:

// Hook the existing game event for "a client just finished connecting" so we
// can push the current k to them. LaunchPadBooster has no public event for
// this. The documented pattern (per LaunchPadBooster authors) is to Harmony-postfix
// NetworkManager.PlayerConnected. We re-broadcast to everyone on each
// connect rather than chase the new client's connectionId; the cost is one
// tiny float message per existing client per join, which is negligible.

IJoinValidator: custom per-mod join validation

LaunchPadBooster exposes a second, per-mod handshake hook beyond the Required version gate. A mod implements IJoinValidator and assigns it to its own ModNetworking.JoinValidator. Both sides then serialize and read a custom payload during the connection-verify exchange; rejection closes the connection with a readable error.

The interface (LaunchPadBooster.Networking.IJoinValidator):

public interface IJoinValidator
{
    void SerializeJoinValidate(RocketBinaryWriter writer);

    bool ProcessJoinValidate(RocketBinaryReader reader, out string error);
}

The per-mod-networking hookup point (LaunchPadBooster.Networking.IModNetworking, ModNetworking):

public IJoinValidator JoinValidator { get; set; }

Invocation flow

IJoinValidator.SerializeJoinValidate and ProcessJoinValidate are invoked strictly inside the Stationeers network connect / verify exchange. The invocation sites are Harmony patches on the vanilla VerifyPlayer and VerifyPlayerRequest messages, applied by LaunchPadBooster in ModNetworking:

[HarmonyPatch(typeof(VerifyPlayerRequest), "Serialize")]
[HarmonyPostfix]
internal static void WriteJoinHeaderServer(RocketBinaryWriter writer)
{
    WriteJoinValidateHeader(writer);
}

[HarmonyPatch(typeof(VerifyPlayer), "Serialize")]
[HarmonyPostfix]
internal static void WriteJoinHeaderClient(RocketBinaryWriter writer)
{
    WriteJoinValidateHeader(writer);
}

[HarmonyPatch(typeof(VerifyPlayerRequest), "Deserialize")]
[HarmonyPostfix]
internal static void ReadJoinHeaderClient(RocketBinaryReader reader)
{
    ReadJoinValidateHeader(GetHostId(), reader);
}

[HarmonyPatch(typeof(VerifyPlayer), "Deserialize")]
[HarmonyPostfix]
internal static void ReadJoinHeaderServer(VerifyPlayer __instance, RocketBinaryReader reader)
{
    ReadJoinValidateHeader(__instance.OwnerConnectionId, reader);
}

VerifyPlayerRequest is the server-to-client prompt and VerifyPlayer is the client-to-server response during a remote join. Both are only exchanged when a remote client negotiates with a host; they do not fire in single-player, and they do not fire on the host loading its own world.

The write side calls PrepareJoinValidateData (ModNetworking.cs approximately lines 693-755), which iterates every ModNetworking.Instances entry and calls each installed validator's SerializeJoinValidate into a shared JoinValidateCustomWriter, framing each mod's bytes with an offset / length entry keyed by mod hash:

internal static JoinValidateData PrepareJoinValidateData(ConnectionState connection)
{
    ...
    foreach (ModNetworking instance in Instances)
    {
        IJoinValidator joinValidator = instance.JoinValidator;
        if (joinValidator != null)
        {
            Mod mod2 = instance.mod;
            int position = joinValidateCustomWriter.Position;
            ...
            try
            {
                joinValidator.SerializeJoinValidate(joinValidateCustomWriter);
            }
            catch (Exception innerException)
            {
                string message = "Error serializing custom join validate for " + mod2.ID.Name + " " + mod2.ID.Version;
                connection.CloseWithError(message);
                throw new Exception(message, innerException);
            }
            list.Add(new JoinValidateModCustomData.Entry
            {
                ModHash = mod2.Hash,
                Offset = position,
                Length = joinValidateCustomWriter.Position - position
            });
        }
    }
    ...
}

The read side is ConnectionState.DoJoinValidateModCustom (ConnectionState.cs approximately lines 441-539). For each entry in the incoming payload, it looks up the matching local ModNetworking by mod hash, slices the frame, and calls the local validator:

private bool DoJoinValidateModCustom(JoinValidateModCustomData custom)
{
    ...
    for (int i = 0; i < array.Length; i++)
    {
        JoinValidateModCustomData.Entry entry = array[i];
        hashSet.Add(entry.ModHash);
        if (ModNetworking.InstancesByHash.TryGetValue(entry.ModHash, out var value))
        {
            IJoinValidator joinValidator = value.JoinValidator;
            if (joinValidator != null)
            {
                ...
                RocketBinaryReader val = new RocketBinaryReader((Stream)new MemoryStream(array2, 0, entry.Length, writable: false));
                try
                {
                    try
                    {
                        if (!joinValidator.ProcessJoinValidate(val, out var error))
                        {
                            list3.Add((value.Hash, error));
                        }
                    }
                    catch (Exception ex)
                    {
                        Debug.LogError((object)("Error running join validator for " + value.ModID));
                        Debug.LogException(ex);
                        list4.Add(value.Hash);
                    }
                    ...
                }
                ...
            }
        }
        ...
    }
    foreach (ModNetworking instance in ModNetworking.Instances)
    {
        if (instance.JoinValidator != null && !hashSet.Contains(instance.Hash))
        {
            list2.Add(instance.Hash);
        }
    }
    ...
}

Missing-validator lists list (remote did not ship this mod's frame) and list2 (local has a validator the remote did not send) both cause connection failure, labelled as "server missing" / "client missing" via NetworkManager.IsServer (see ConnectionState.cs approximately lines 510-538).

When the validator fires

  • Remote client joining a host: fires on both sides. Host sends its SerializeJoinValidate bytes inside VerifyPlayerRequest; client reads them via ProcessJoinValidate. Client sends back its own bytes inside VerifyPlayer; host reads them via ProcessJoinValidate. Either side returning false causes CloseWithError with a concatenated reason list.
  • Host starting or loading its own world: does NOT fire. The VerifyPlayer / VerifyPlayerRequest exchange is purely the remote-join handshake; there is no analogous self-verify for the host's own session start.
  • Pure single-player: does NOT fire. Same reason as the host-own-world case. Single-player never issues the verify messages that the LaunchPadBooster postfixes piggyback on.

Practical consequence: IJoinValidator cannot be used to enforce a host-side restart-required check against the host's own loaded world. It only protects against remote-client mismatches. Host-side self-consistency must be solved by other means (for example, RequireRestart advisory UI in the settings panel, a scene-load check, or accepting a silent mismatch with documented behavior).

Error surface

ConnectionState.CloseWithError is called with a multi-line string composed of each failing mod's ProcessJoinValidate error. The string includes "server" / "client" framing derived from NetworkManager.IsServer at the point of check, so the displayed message tells the player which side is missing or rejecting the value.

ModNetworking also exposes JoinPrefixSerializer and VersionValidator hooks for the same general exchange, with different semantics. Not in scope on this page; see IModNetworking for the full surface.

IJoinSuffixSerializer: join-time state snapshot to a joining client

IJoinSuffixSerializer is the hook for shipping a block of per-mod state to a joining client as part of the world snapshot. It is distinct from IJoinValidator (a pass/fail handshake) and from live INetworkMessage broadcasts (which only reach already-connected clients). A plugin implements it and assigns MOD.Networking.JoinSuffixSerializer = this.

public interface IJoinSuffixSerializer
{
    void SerializeJoinSuffix(RocketBinaryWriter writer);
    void DeserializeJoinSuffix(RocketBinaryReader reader);
}

Invocation (verified from Mods/PowerTransmitterPlus/PowerTransmitterPlus/Plugin.cs:260-348, DistanceConfigSync.cs:9-23, and ./PlayerConnectedThingFindTiming.md):

  • Host: SerializeJoinSuffix runs inside NetworkServer.PackageJoinData (LaunchPadBooster injects it into the join writer), appended to the world snapshot sent to the joiner.
  • Client: DeserializeJoinSuffix runs inside NetworkClient.ProcessJoinData, at the position of the original AtmosphericsManager.DeserializeOnJoin call, which is AFTER ProcessThings. So every Thing is already deserialized and Thing.Find resolves device ids inside the deserializer.
  • Fires only on a remote client join. Does NOT fire when the host loads its own world, nor in single-player (same gating as IJoinValidator).
  • LaunchPadBooster length-prefixes each mod's section (SectionedWriter / Reader), so a schema change in one mod does not desync neighbours. Field write order in SerializeJoinSuffix MUST match read order in DeserializeJoinSuffix.

Why this and not a NetworkManager.PlayerConnected broadcast: that hook fires BEFORE the joiner is added to NetworkBase.Clients, so an INetworkMessage.SendAll from a PlayerConnected postfix reaches existing clients but never the new joiner. PowerTransmitterPlus used the PlayerConnected rebroadcast through v1.6.x and removed it in v1.7.0 in favour of IJoinSuffixSerializer for exactly this reason. The established split: live SendAll for runtime changes to already-connected clients, IJoinSuffixSerializer for the initial snapshot to a joiner.

A Thing the join spawns has already run Awake

Measured on the TestRig client half at game 0.2.6428.27798 (a listen host and one joined client), with the check Mods/SprayPaintPlus/playtests/JoinerGunSlotFollowsServerGlow.cs. When DeserializeJoinSuffix runs on the joiner, a Thing the join spawned has already been through Awake, a Harmony postfix on it included. Whatever such a postfix decided from client-side values was decided before the mod's join payload could be read.

The observation. A spray gun in the host's hand is part of the join. Spray Paint Plus decides the gun's can slot in a postfix on its Awake, from its merged glow setting, whose server half on a joiner is the host's value from the join payload and, until that payload lands, the joiner's own entry. With the host's entry off and the joiner's own entry on, the joiner read the gun's Slots[0].Type as Blocked after the join under Spray Paint Plus v1.12.0, a build with nothing that revisits the gun, although the same payload had delivered the host's value (SettingsMerge.SyncedGlowPaint read False). The joiner's console shows the game's ThingCount: 226 line before the mod's join-suffix line, [Spray Paint Plus] This server does not allow Glow Paint. Your own settings are untouched, they just have no effect here.

Two consequences for a mod:

  • Per-Thing state decided at Awake from values the join suffix carries has to be revisited from DeserializeJoinSuffix. Spray Paint Plus v1.12.1 does that (GlowPaintPatches.ReapplySlotState over OcclusionManager.AllThings), and under it the same joiner read Bottle, the vanilla slot, after the join.
  • Inside DeserializeJoinSuffix the client already counts as a remote client: an authority test of the form !NetworkManager.IsActive || NetworkManager.IsServer is false there. That is why the v1.12.1 revisit resolved the host's value (glow off) rather than the joiner's own entry (glow on); resolved as the authority, it would have left the slot Blocked.

What an exception from a mod callback reaches

Read in a decompile of LaunchPadBooster.dll ([assembly: AssemblyVersion("0.2.0.0")]) as installed alongside game 0.2.6428.27798. None of the four places LaunchPadBooster calls into a mod's serializers catches an exception from mod code, and each one has its own framing, which decides what an exception can damage.

INetworkMessage.Serialize runs synchronously, inside the sender's own call. SendMessageDirect (1851) and SendMessageAll (1874) build the payload inline, before any packet exists:

using PacketEncoder packetEncoder = new PacketEncoder(NextSequence, PacketType.Message, SerializeMessage(new MessageHeader
{
    TypeID = typeID
}, message));
internal static Span<byte> SerializeMessage(MessageHeader header, INetworkMessage message)
{
    //IL_000f: Unknown result type (might be due to invalid IL or missing references)
    RocketBinaryWriter val = InitWriter(header);
    message.Serialize(val);
    return val.AsSpan();
}

So an exception from Serialize propagates out of the SendToHost / SendToClient / SendAll call the mod itself made, and nothing is sent. The sequence number NextSequence already handed out is skipped, which is harmless: the receiver compares sequence numbers only to match a continuation packet to its start packet (2363-2367):

num = continuePacket.Sequence & 0xFFFFFFFF7FFFFFFFuL;
if (num != Incoming.Sequence || continuePacket.Offset != IncomingOffset || IncomingBuffer == null)
{
    Debug.LogWarning((object)$"Unexpected continue packet {num}@{continuePacket.Offset}. Expected {Incoming.Sequence}@{IncomingOffset}");

Every received message gets its own bounded stream, and nothing catches. ConnectionState.ReceiveMessage (2720-2750):

INetworkMessage networkMessage = ctor();
using MemoryStream memoryStream = new MemoryStream(arraySegment.Array, arraySegment.Offset, arraySegment.Count, writable: false);
RocketBinaryReader val = new RocketBinaryReader((Stream)memoryStream);
try
{
    networkMessage.Deserialize(val);
    networkMessage.Process(ConnectionID);
}
finally
{
    ((IDisposable)val)?.Dispose();
}

A short or failed read cannot reach any byte outside that one message. An exception from Deserialize or Process propagates out of ReceiveMessage and out of CompleteReceive, whose caller only resets its buffer on the way (2376-2383):

try
{
    CompleteReceive();
}
finally
{
    ResetIncoming();
}

Join-suffix sections are bounded per mod. SectionedReader.Next (3290-3307) hands the mod a reader over exactly its own section, and has already moved the outer stream past the section before the mod reads a byte:

remaining--;
modHash = fullReader.ReadInt32();
int num = fullReader.ReadInt32();
int index = (int)stream.Position;
reader = new RocketBinaryReader((Stream)new MemoryStream(buffer, index, num, writable: false));
stream.Seek(num, SeekOrigin.Current);
return true;

On the write side SectionedWriter.FinishSection (3253-3258) back-patches the length from the writer's positions, so a SerializeJoinSuffix that stops early leaves a short but well-formed section:

internal void FinishSection()
{
    writer.Position = sectionPos - 4;
    writer.WriteInt32(writer.Length - sectionPos);
    writer.Position = writer.Length;
}

Neither side catches. WriteJoinSuffix (1756-1772) calls joinSuffixSerializer.SerializeJoinSuffix(writer) with no try, so a throw propagates into the host's join packaging. ReadJoinSuffix (1797) is an async UniTask; its state machine (1290-1380) runs the whole section loop, joinSuffixSerializer.DeserializeJoinSuffix(val3) included, inside the compiler-generated catch that ends in SetException(exception), so a throw from DeserializeJoinSuffix faults the join on the joining client.

What this means for a mod. A try/catch around the WHOLE body of Deserialize or DeserializeJoinSuffix cannot misalign anything outside the mod's own message or section: the framing contains it. Inside the body the usual rule still applies, because a half-read section leaves the mod's own fields half-assigned: read everything first, assign nothing until the last read has succeeded. A catch that swallows a Serialize failure would send a header with a short body, whereas letting it propagate to the mod's own send call sends nothing. None of this carries over to the vanilla per-Thing streams (BuildUpdate, ProcessUpdate, SerializeOnJoin, DeserializeOnJoin), which have no per-mod framing at all; see BinaryStreamSafety.

RocketBinaryWriter / RocketBinaryReader: the vanilla primitive surface

Every serializer hook on this page (IJoinValidator.SerializeJoinValidate / ProcessJoinValidate, IJoinSuffixSerializer.SerializeJoinSuffix / DeserializeJoinSuffix, and LaunchPadBooster message serialization generally) hands the mod a RocketBinaryWriter or RocketBinaryReader. These are VANILLA game types in Assets.Scripts.Networking (Assembly-CSharp; namespace declaration at 0.2.6403.27689 decompile line 272552), not LaunchPadBooster types:

  • public class RocketBinaryReader : RocketBinaryCore (274266, spans 274266-274519): wraps a Stream; every multi-byte read goes through ReadExactly (274284), which throws EndOfStreamException on a short read. Reader methods are virtual (subclass hook PostRead, 274275).
  • public class RocketBinaryWriter : RocketBinaryCore (274520, spans 274520-274839): writes into an ArrayPool<byte>-rented buffer (constructor takes bufferSize, 274549) guarded by Ensure(count) (274560-274566, throws OverflowException("Buffer too small for message.")). Writer methods are plain instance methods (hook PreWrite, 274556).

Float payloads round-trip through WriteSingle / ReadSingle as 4-byte little-endian IEEE 754 singles, verbatim:

public void WriteSingle(float value)                     // RocketBinaryWriter, line 274644
{
    Ensure(4);
    int value2 = BitConverter.SingleToInt32Bits(value);
    BinaryPrimitives.WriteInt32LittleEndian(_buffer.AsSpan(Position, 4), value2);
    Position += 4;
}

public virtual float ReadSingle()                        // RocketBinaryReader, line 274380
{
    Span<byte> span = stackalloc byte[4];
    ReadExactly(span);
    return BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(span));
}

Complete primitive inventory at 0.2.6403.27689 (writer line / reader line): Boolean 274574/274300, Byte 274580/274305, SByte 274586/274315, Bytes 274592/274325, Int32 274602/274338, UInt32 274609/274345, Int16 274616/274352, UInt16 274623/274359, Int64 274630/274366, UInt64 274637/274373, Single 274644/274380, Double 274652/274392, FloatHalf 274715/274387 (half precision via Mathf.FloatToHalf on write and RocketMath.HalfToFloat(ReadUInt16()) on read; rounding consequences on Float16Quantization), Quaternion 274660/274399 and QuaternionHalf 274668/274404 (the read side renormalizes: .normalized), Colour 274676/274409, AnimationCurve 274684/274414, Vector3 274701/274435 and Vector3Half 274720/274445, Vector3d 274708/274440 (doubles), WorldGrid 274727/274450 (stored as Int16 of value/10), Grid3 274734/274455, String 274741/274460 (Int32 byte-length prefix + UTF8; writer emits length -1 for null, reader returns string.Empty for -1 or 0 and throws InvalidDataException on other negatives), NetworkUpdateType 274763/274504, MessageType 274568/274279 (a MessageFactory type index byte), Ascii 274799/274509. Writer-side positioning extras: Position / Length properties (274530/274547), Seek(int, SeekOrigin) 274768, SeekZero 274784, SeekEnd 274789, Seek(long, SeekOrigin) 274794, plus buffer lifecycle (ReturnBuffer 274808, Close 274817, Dispose 274823, Reset 274829).

Mod consequence: a custom payload carrying floats writes writer.WriteSingle(x) on one side and reads x = reader.ReadSingle() on the other for a full 32-bit round trip; reach for the FloatHalf pair only when half-precision rounding is acceptable. Write order must equal read order field for field, and no try-catch goes around the calls (BinaryStreamSafety): a swallowed exception leaves the stream position misaligned for every subsequent field.

Wire format: TypeID, channel, compression, size ceiling, and the shared writer

Five facts about how a mod message actually reaches the wire. All five have consequences a mod author can trip over, and the first is a silent break.

A message's wire identity is its class's full name, not its registration order. Registry<T>.RegisterType<T2> computes it once:

internal void RegisterType<T2>(Mod mod) where T2 : T, new()
{
    TypeID typeID = new TypeID(mod.Hash, Animator.StringToHash(typeof(T2).FullName));
    typeIDs.Add(typeof(T2), typeID);
    types.Add(typeID, typeof(T2));
    ctors.Add(typeID, Construct<T2>);
}

TypeID is readonly struct TypeID(int ModHash, int TypeHash) (3691) and goes on the wire as two Int32s (ReadTypeID 3057, WriteTypeID 3133) in every MessageHeader (3529-3541). Two consequences, in opposite directions:

  • Renaming a message class, or moving it to a different namespace, is a wire break with no compile error. typeof(T).FullName includes the namespace, so SprayPaintPlus.GlowStateMessage and SprayPaintPlus.Sync.GlowStateMessage hash differently. The receiving peer looks the id up in types (TypeFor, 3771), misses, and the message is dropped. Nothing logs a rename as a rename.
  • Adding a message type is safe. Ids are derived, not sequential, so appending a sixth RegisterMessage<T>() call cannot renumber the first five, and the order of the RegisterMessage calls does not matter as long as both peers register the same set of types. TypeIDFor throws InvalidOperationException($"unregistered {typeof(T).Name} type {type}") (3757-3764) for a type that was never registered, so a forgotten registration fails loudly on send.

Channel 169, which is outside the vanilla enum entirely. internal const NetworkChannel BoosterNetworkChannel = (NetworkChannel)169; (1390). Vanilla NetworkChannel runs GeneralTraffic = 134 through PhysicsTick = 141 and NumChannels = 142 (Assembly-CSharp 278169-278180), so 169 is not a vanilla channel and never collides with one. It is also not reachable by vanilla's own dispatch, whose switch ends in default: throw new ArgumentOutOfRangeException(); LaunchPadBooster patches NetworkManager.ReceiveEvents (patch at 1106) and funnels the unknown channel into its own handler, which refuses anything else:

internal static bool ReceiveUnknownEvent(int channel, int size, long connectionId)
{
    if (channel != 169)
    {
        throw new ArgumentOutOfRangeException($"{channel}");
    }
    ReceivePacket(connectionId, MemoryExtensions.AsSpan<byte>(NetworkManagerBuffer, 0, size));
    return true;
}

Delivery is reliable and ordered. The channel byte is written as byte 0 of the RakNet packet and the reliability comes from the channel, in NetworkManager.SendNetworkDataDirect (Assembly-CSharp 279609-279611):

PacketPriority priority = PacketPriority.HighPriority;
PacketReliability reliability = ((channel == NetworkChannel.PhysicsTick) ? PacketReliability.ReliableSequenced : PacketReliability.ReliableOrdered);
byte orderingChannel = ((channel == NetworkChannel.PhysicsTick) ? ((byte)1) : ((byte)0));

PhysicsTick is the only channel treated differently, so mod traffic on 169 is ReliableOrdered at HighPriority on ordering channel 0, the same ordering channel as GeneralTraffic, PlayerJoin and StateTick. Mod messages are therefore delivered, and delivered in send order relative to each other and to those vanilla streams. (This is the RocketNet / RakNet transport; the Steam P2P path passes the channel through to SendDataToSteamP2PConnection instead.)

LZ4 above 960 bytes, then chunked at 960. internal const int MaxDataPerPacket = 960; (1392). PacketEncoder's constructor compresses only when it is worth it, and keeps the compressed buffer only when it actually got smaller:

if (decompSize <= 960)
{
    compSize = decompSize;
    return;
}
byte[] array = ArrayPool<byte>.Shared.Rent(LZ4Codec.MaximumOutputLength(decompSize));
compSize = LZ4Codec.Encode(buffer, 0, decompSize, array, 0, array.Length);
if (compSize < decompSize)
{
    ArrayPool<byte>.Shared.Return(buffer, false);
    buffer = array;
}
else
{
    compSize = decompSize;
    ArrayPool<byte>.Shared.Return(array, false);
}

Whatever survives that is then split by Chunk(), which takes Math.Min(compSize - offset, 960) per packet, so a large message becomes as many packets as it needs with no work from the mod. Note that the 960 threshold is tested against the uncompressed size, and incompressible data above 960 bytes is sent uncompressed rather than expanded.

MaxMessageSize is 1 MiB. const int MaxMessageSize = 1048576; on IModNetworking (3545), alongside MultipartReceiveTimeout = 1f and RpcCallTimeout = 30f. A payload that exceeds it fails inside Serialize, which means the exception surfaces at the send site rather than producing a truncated message on the wire. A mod sending an unbounded collection should size the worst case against this number.

InitWriter hands out one shared static writer, so serialization is not reentrant. The field is a single static instance (internal static readonly RocketBinaryWriter messageWriter;, 1400) and every send starts by rewinding it:

internal static RocketBinaryWriter InitWriter<T>(T header) where T : IPacket
{
    messageWriter.Position = 1024;
    SpanIO writer = new SpanIO(messageWriter.AsSpan());
    header.Write(ref writer);
    messageWriter.Reset();
    messageWriter.Position = writer.Position;
    return messageWriter;
}

SendMessageDirect, SendMessageAll and the RPC path (2001) all go through it. So a mod must never send a second message from inside the Serialize of a first one: the nested InitWriter rewinds the writer the outer call is still filling, and the outer message goes out corrupt or truncated. The same hazard applies to any code reachable from Serialize that might send, which includes a logging or event callback that another mod has hooked. Keep Serialize to writing fields. This is a stronger constraint than the ordinary "write order must equal read order" rule on the primitives above, because it can be violated without the offending code appearing anywhere near the serializer.

The related NetworkVersion constant is 2 (1388).

Verification history

  • 2026-04-20: page created from the Research migration. Primary source F0055 (PowerTransmitterPlus RESEARCH.md:676-692). Additional sources cited: F0025 (SprayPaintPlus RESEARCH.md:211-213), F0029c (SprayPaintPlus RESEARCH.md:149-151), F0312 (PowerTransmitterPlus/DistanceConfigSync.cs:66-72).
  • 2026-04-23: added "IJoinValidator: custom per-mod join validation" section. Verified against LaunchPadBooster.dll in game version 0.2.6228.27061 by decompilation of IJoinValidator, IModNetworking, ModNetworking.PrepareJoinValidateData, ModNetworking VerifyPlayer / VerifyPlayerRequest Harmony postfixes, and ConnectionState.DoJoinValidateModCustom. Finding: validator fires only on remote client-join exchanges; does NOT fire for host-own-world load or single-player.
  • 2026-05-28: added "IJoinSuffixSerializer: join-time state snapshot to a joining client". Verified from PowerTransmitterPlus Plugin.cs:260-348 (implements IJoinSuffixSerializer; SerializeJoinSuffix / DeserializeJoinSuffix ship the auto-aim cache + seven config values) and DistanceConfigSync.cs:9-23, cross-referenced with ./PlayerConnectedThingFindTiming.md. Documents the host PackageJoinData / client ProcessJoinData-after-ProcessThings invocation points, the remote-join-only gating, and why it replaced the v1.6.x PlayerConnected rebroadcast (which fires before the joiner is in NetworkBase.Clients). Additive (the page previously documented only IJoinValidator, with IJoinPrefixSerializer noted out of scope); no existing claim contradicted, so no fresh validator.
  • 2026-07-14: added "RocketBinaryWriter / RocketBinaryReader: the vanilla primitive surface" (game version 0.2.6403.27689), from the PowerGridPlus fault-hover session's float-payload check. Both classes read in full from the 0.2.6403.27689 decompile: RocketBinaryReader : RocketBinaryCore 274266-274519 (Stream-backed, ReadExactly 274284, virtual methods, PostRead hook 274275) and RocketBinaryWriter : RocketBinaryCore 274520-274839 (ArrayPool buffer, Ensure 274560-274566, PreWrite hook 274556); namespace Assets.Scripts.Networking confirmed at 272552. WriteSingle (274644-274650) and ReadSingle (274380-274385) quoted verbatim: 4-byte little-endian IEEE 754 via BitConverter.SingleToInt32Bits / Int32BitsToSingle, confirming full-precision float payloads for custom network messages (the FloatHalf pair at 274715 / 274387 remains the only half-precision path). Full primitive inventory recorded with paired line references. Additive; no existing claim contradicted (the page previously used these types in verbatim LaunchPadBooster excerpts without documenting the game-side surface). Top-level verified_in bumped to 0.2.6403.27689 for this section; earlier sections keep their 0.2.6228.27061 stamps and were not re-read this pass.

  • 2026-08-11: added "The Required gate is SYMMETRIC..." under Handshake / version, from a live three-arm experiment on the client rig plus the LaunchPadBooster decompile (ConnectionState.DoJoinValidateMod 2095-2172, FailClientJoin 1399-1405). Fifteen join attempts, five per arm: mods matched-off 5/5 joined, matched-on 5/5 joined, host-only 0/5 with the Incompatible mods modal naming both mods and both hashes, every failure inside 4 s. Additive to the one-line claim already on the page (which said only that a client without the mod is rejected); it does not contradict it, but it does correct the natural reading that the gate is one-directional. The measured direction is the opposite one: the joiner was rejected for mods only the SERVER had. Also recorded because it is the operational explanation for a class of driven-join failure that had been attributed to other causes.

  • 2026-09-21: added "What an exception from a mod callback reaches" at game 0.2.6428.27798, from a fresh ilspycmd decompile of the installed LaunchPadBooster.dll (AssemblyVersion 0.2.0.0) made during the Spray Paint Plus v1.12.0 entry-point hardening. All excerpts are verbatim from that decompile, line ranges in the frontmatter. Additive: it confirms and details the existing one-line claim under "IJoinSuffixSerializer" that each mod's section is length-prefixed, and contradicts nothing. The top-level verified_in moved to 0.2.6428.27798 for this section only; the other sections keep their stamps and were not re-read this pass.

  • 2026-09-24: added "Wire format: TypeID, channel, compression, size ceiling, and the shared writer" at game 0.2.6428.27798, from the installed LaunchPadBooster.dll decompile plus the vanilla send and dispatch paths in Assembly-CSharp. Five verified facts, all additive and none contradicting anything on the page: the TypeID is (mod.Hash, Animator.StringToHash(typeof(T).FullName)) (3751), so a class rename or namespace move is a silent wire break while adding a type is safe; channel 169 (1390) sits outside the vanilla NetworkChannel enum (Assembly-CSharp 278169-278180, which ends at NumChannels = 142) and is reachable only because LaunchPadBooster patches NetworkManager.ReceiveEvents and funnels it to ReceiveUnknownEvent (1943-1952); delivery is ReliableOrdered at HighPriority on ordering channel 0, because SendNetworkDataDirect (Assembly-CSharp 279609-279611) special-cases only PhysicsTick; PacketEncoder LZ4-compresses only above 960 uncompressed bytes and only keeps the result when it is smaller, then chunks at 960 (3160-3192); MaxMessageSize is 1048576 (3545). The fifth is the one with teeth: InitWriter (1644-1653) returns a single shared static messageWriter (1400), so sending a message from inside another message's Serialize corrupts the outer one. Two claims were sharpened during verification rather than taken as given: the true argument at the send site (1641) is logWarning, not a reliability flag, so reliability was traced to the channel mapping instead; and compression is attempted above 960 rather than applied unconditionally.

  • 2026-09-22: added "A Thing the join spawns has already run Awake" under "IJoinSuffixSerializer" at game 0.2.6428.27798, from the Spray Paint Plus v1.12.1 slot check on the TestRig client half: one run against v1.12.0 (the joiner's slot read Blocked after the join, the synced host value read False) and two against the v1.12.1 working tree (the slot read Bottle). Additive: it refines the existing claim that every Thing is already deserialized when DeserializeJoinSuffix runs, and contradicts nothing. The rest of the IJoinSuffixSerializer section was not re-read and keeps its 0.2.6228.27061 stamp.
  • 2026-09-25: re-verified the INetworkMessage.Process(long hostId) line under "Primitives" at game 0.2.6428.27798 against the installed LaunchPadBooster.dll 0.2.0 (byte-identical on the client and dedicated-server installs), prompted by a Spray Paint Plus change that trusts the id to identify a message's sender. The line is correct as written and is kept verbatim. Added the subsection "Where Process's id comes from, and why a vanilla message has none" with the evidence: ConnectionState.ReceiveMessage calls networkMessage.Process(ConnectionID) (2744) only when Status == ConnectionStatus.Ready; the ConnectionState is the one GetConnection (1513-1521) finds under the id AddConnection registered from the NetworkManager.PlayerConnected postfix; packets reach it through the TranspileReceiveEvents transpiler (1106-1153), which replaces the unknown-channel throw with ldloc.1; ldloc.2; ldloc.3; call ReceiveUnknownEvent; ret, and the ReceiveEvents IL .locals init (read with ilspycmd -il on both installs, identical) makes those the channel, the size and the per-packet sender id (SteamId.Value for Steam P2P, ptr->Guid.G for RakNet), the value vanilla also passes to PlayerConnected. Also recorded: the interface itself names the parameter clientId (3577) although the value is a connection id; on a server the honest sender's Client.connectionId is the same number, because VerifyConnection builds it from the echoed VerifyPlayer.OwnerConnectionId, and LaunchPadBooster keys its server-side join state on that echo too (ReadJoinHeaderServer, ReceiveVerifyPlayer); and the contrast with vanilla, whose HandleGeneralTraffic(_hostId, num2) dispatch gives every ProcessedMessage on a server hostId -1, because _hostId starts at -1, is reset to -1 in EndConnection and is written only on client paths. Additive: nothing on the page is contradicted, so no fresh validator. "Primitives" re-read in full (every member it lists is present in the 0.2.0 decompile, Required still gates the join in DoJoinValidateModList (2472-2552), SetMultiplayerRequired is still [Obsolete(..., true)], LaunchPadBooster declares no public events, SendMessageAll skips a client whose connectionId equals the excluded id so 0L excludes no joiner) and restamped 0.2.6428.27798.

Open questions

  • The VersionValidator.ValidateVersion default is not documented here. The gate distinguishes "missing" from "version incompatible" and from "the validator threw", and only the missing case has been observed live.
  • What a joining client that echoes an OwnerConnectionId other than its own connection id ends up with. The server builds its Client record, and LaunchPadBooster files its join-validate header and its copy of VerifyPlayer, under the echoed id, while the joiner's packets keep arriving under the transport id, and nothing compares the two. Which ConnectionState then reaches Ready, and whether Client.Find(long) resolves that joiner's mod messages at all, has not been traced end to end or measured. An honest client always echoes the id it was sent.
  • Whether a Thing the join places inside a container runs Awake during the join, as the hand-held spray gun measured on 2026-09-22 does, or only when its GameObject is first activated. Only a Thing held in a hand has been measured.