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.
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.
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.
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.
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.
Open questions¶
None at creation.