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.
- 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.Valuefor 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 withilspycmd -ilon both installs'Assembly-CSharp.dll; the two methods' IL is identical. - LaunchPadBooster's transpiler on
ReceiveEvents(TranspileReceiveEvents, 1106-1153) finds thenewobj ArgumentOutOfRangeException/throwpair at the end of the channel switch, removes it, and insertsldloc.1,ldloc.2,ldloc.3,call ReceiveUnknownEvent(int, int, long),ret. So channel 169 reachesReceiveUnknownEvent(int channel, int size, long connectionId)(1943-1952) with local 3 asconnectionId. ReceivePacket(1623-1628) hands the packet toGetConnection(connectionID)(1513-1521), a dictionary lookup keyed by the id thatAddConnection(1531-1535) registered from LaunchPadBooster's postfix onNetworkManager.PlayerConnected(1162-1168). Vanilla callsPlayerConnectedwith 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 newConnectionStateand the warningReceived data on unregistered connection {connectionID}.ConnectionState.ReceiveMessage(2720-2750) drops the message unlessStatus == ConnectionStatus.Ready, which is set in one place, when the join validation passes (2445), and otherwise callsnetworkMessage.Process(ConnectionID)(2744).ConnectionIDis 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.InstancesByHashmatch, ifitem.Requiredit goes on the "this side is missing it" list; - for each local
ModNetworking.Instancesentry whose hash is absent from the incoming set, ifinstance.Requiredit 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:
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):
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
SerializeJoinValidatebytes insideVerifyPlayerRequest; client reads them viaProcessJoinValidate. Client sends back its own bytes insideVerifyPlayer; host reads them viaProcessJoinValidate. Either side returningfalsecausesCloseWithErrorwith a concatenated reason list. - Host starting or loading its own world: does NOT fire. The
VerifyPlayer/VerifyPlayerRequestexchange 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.
Related: IJoinPrefixSerializer and IVersionValidator¶
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:
SerializeJoinSuffixruns insideNetworkServer.PackageJoinData(LaunchPadBooster injects it into the join writer), appended to the world snapshot sent to the joiner. - Client:
DeserializeJoinSuffixruns insideNetworkClient.ProcessJoinData, at the position of the originalAtmosphericsManager.DeserializeOnJoincall, which is AFTERProcessThings. So every Thing is already deserialized andThing.Findresolves 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
SerializeJoinSuffixMUST match read order inDeserializeJoinSuffix.
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
Awakefrom values the join suffix carries has to be revisited fromDeserializeJoinSuffix. Spray Paint Plus v1.12.1 does that (GlowPaintPatches.ReapplySlotStateoverOcclusionManager.AllThings), and under it the same joiner readBottle, the vanilla slot, after the join. - Inside
DeserializeJoinSuffixthe client already counts as a remote client: an authority test of the form!NetworkManager.IsActive || NetworkManager.IsServeris 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 slotBlocked.
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):
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 aStream; every multi-byte read goes throughReadExactly(274284), which throwsEndOfStreamExceptionon a short read. Reader methods arevirtual(subclass hookPostRead, 274275).public class RocketBinaryWriter : RocketBinaryCore(274520, spans 274520-274839): writes into anArrayPool<byte>-rented buffer (constructor takesbufferSize, 274549) guarded byEnsure(count)(274560-274566, throwsOverflowException("Buffer too small for message.")). Writer methods are plain instance methods (hookPreWrite, 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).FullNameincludes the namespace, soSprayPaintPlus.GlowStateMessageandSprayPaintPlus.Sync.GlowStateMessagehash differently. The receiving peer looks the id up intypes(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 theRegisterMessagecalls does not matter as long as both peers register the same set of types.TypeIDForthrowsInvalidOperationException($"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.dllin game version 0.2.6228.27061 by decompilation ofIJoinValidator,IModNetworking,ModNetworking.PrepareJoinValidateData,ModNetworkingVerifyPlayer/VerifyPlayerRequestHarmony postfixes, andConnectionState.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(implementsIJoinSuffixSerializer;SerializeJoinSuffix/DeserializeJoinSuffixship the auto-aim cache + seven config values) andDistanceConfigSync.cs:9-23, cross-referenced with./PlayerConnectedThingFindTiming.md. Documents the hostPackageJoinData/ clientProcessJoinData-after-ProcessThingsinvocation points, the remote-join-only gating, and why it replaced the v1.6.xPlayerConnectedrebroadcast (which fires before the joiner is inNetworkBase.Clients). Additive (the page previously documented onlyIJoinValidator, withIJoinPrefixSerializernoted 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 : RocketBinaryCore274266-274519 (Stream-backed,ReadExactly274284, virtual methods,PostReadhook 274275) andRocketBinaryWriter : RocketBinaryCore274520-274839 (ArrayPool buffer,Ensure274560-274566,PreWritehook 274556); namespaceAssets.Scripts.Networkingconfirmed at 272552.WriteSingle(274644-274650) andReadSingle(274380-274385) quoted verbatim: 4-byte little-endian IEEE 754 viaBitConverter.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-levelverified_inbumped 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.DoJoinValidateMod2095-2172,FailClientJoin1399-1405). Fifteen join attempts, five per arm: mods matched-off 5/5 joined, matched-on 5/5 joined, host-only 0/5 with theIncompatible modsmodal 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
ilspycmddecompile of the installedLaunchPadBooster.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-levelverified_inmoved 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.dlldecompile plus the vanilla send and dispatch paths inAssembly-CSharp. Five verified facts, all additive and none contradicting anything on the page: theTypeIDis(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 vanillaNetworkChannelenum (Assembly-CSharp 278169-278180, which ends atNumChannels = 142) and is reachable only because LaunchPadBooster patchesNetworkManager.ReceiveEventsand funnels it toReceiveUnknownEvent(1943-1952); delivery isReliableOrderedatHighPriorityon ordering channel 0, becauseSendNetworkDataDirect(Assembly-CSharp 279609-279611) special-cases onlyPhysicsTick;PacketEncoderLZ4-compresses only above 960 uncompressed bytes and only keeps the result when it is smaller, then chunks at 960 (3160-3192);MaxMessageSizeis 1048576 (3545). The fifth is the one with teeth:InitWriter(1644-1653) returns a single shared staticmessageWriter(1400), so sending a message from inside another message'sSerializecorrupts the outer one. Two claims were sharpened during verification rather than taken as given: thetrueargument at the send site (1641) islogWarning, 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
Blockedafter the join, the synced host value readFalse) and two against the v1.12.1 working tree (the slot readBottle). Additive: it refines the existing claim that every Thing is already deserialized whenDeserializeJoinSuffixruns, 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 installedLaunchPadBooster.dll0.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 "WhereProcess's id comes from, and why a vanilla message has none" with the evidence:ConnectionState.ReceiveMessagecallsnetworkMessage.Process(ConnectionID)(2744) only whenStatus == ConnectionStatus.Ready; theConnectionStateis the oneGetConnection(1513-1521) finds under the idAddConnectionregistered from theNetworkManager.PlayerConnectedpostfix; packets reach it through theTranspileReceiveEventstranspiler (1106-1153), which replaces the unknown-channel throw withldloc.1; ldloc.2; ldloc.3; call ReceiveUnknownEvent; ret, and theReceiveEventsIL.locals init(read withilspycmd -ilon both installs, identical) makes those the channel, the size and the per-packet sender id (SteamId.Valuefor Steam P2P,ptr->Guid.Gfor RakNet), the value vanilla also passes toPlayerConnected. Also recorded: the interface itself names the parameterclientId(3577) although the value is a connection id; on a server the honest sender'sClient.connectionIdis the same number, becauseVerifyConnectionbuilds it from the echoedVerifyPlayer.OwnerConnectionId, and LaunchPadBooster keys its server-side join state on that echo too (ReadJoinHeaderServer,ReceiveVerifyPlayer); and the contrast with vanilla, whoseHandleGeneralTraffic(_hostId, num2)dispatch gives everyProcessedMessageon a serverhostId-1, because_hostIdstarts at -1, is reset to -1 inEndConnectionand 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,Requiredstill gates the join inDoJoinValidateModList(2472-2552),SetMultiplayerRequiredis still[Obsolete(..., true)], LaunchPadBooster declares no public events,SendMessageAllskips a client whoseconnectionIdequals the excluded id so0Lexcludes no joiner) and restamped 0.2.6428.27798.
Open questions¶
- The
VersionValidator.ValidateVersiondefault 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
OwnerConnectionIdother than its own connection id ends up with. The server builds itsClientrecord, and LaunchPadBooster files its join-validate header and its copy ofVerifyPlayer, under the echoed id, while the joiner's packets keep arriving under the transport id, and nothing compares the two. WhichConnectionStatethen reachesReady, and whetherClient.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
Awakeduring 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.