PlayerConnected message timing vs world-load on joining clients¶
Verbatim source-backed answer to "is it safe to broadcast an INetworkMessage from a Harmony postfix on NetworkManager.PlayerConnected and have a joining client resolve Thing.Find(referenceId) in its Process() callback?"
Summary¶
No. A broadcast from NetworkManager.PlayerConnected postfix on the host does not reach the joining client at all, because at that moment the joiner is not yet in NetworkBase.Clients. Even if it did, the LaunchPadBooster receive path would drop it because the connection is not yet ConnectionStatus.Ready.
The correct hook for "deliver per-Thing mod state to a joining client at world-snapshot time" is LaunchPadBooster.Networking.IJoinSuffixSerializer. Its DeserializeJoinSuffix runs after ProcessThings has populated the client's Thing registry, so Thing.Find resolves.
Server-side join sequence¶
NetworkManager.PlayerConnected is a private static method called from four sites in NetworkManager.cs: Steam P2P server (line 271), Steam P2P client (line 695), RocketNet NewIncomingConnection (line 731), RocketNet ConnectionRequestAccepted (line 737). All fire at TCP-accept time, before any verify handshake.
// NetworkManager.cs:760
private static void PlayerConnected(long connectionId, ConnectionMethod connectionMethod)
{
switch (NetworkRole)
{
case NetworkRole.Server:
NetworkServer.ClientConnected(connectionId, connectionMethod);
break;
case NetworkRole.Client:
NetworkClient.Connected(connectionId, connectionMethod);
break;
}
}
On the server side this dispatches to NetworkServer.ClientConnected, which only sends VerifyPlayerRequest. It does NOT add the client to any list:
// NetworkServer.cs:191
public static void ClientConnected(long connectionId, ConnectionMethod connectionMethod)
{
NetworkMessages.VerifyPlayerRequest message = new NetworkMessages.VerifyPlayerRequest
{
ClientConnectionID = connectionId,
ClientConnectionMethod = connectionMethod,
PasswordRequired = !string.IsNullOrEmpty(Settings.CurrentData.ServerPassword)
};
NetworkManager.SendNetworkMessageDirect(connectionId, connectionMethod, NetworkChannel.GeneralTraffic, message);
}
NetworkBase.AddClient(client) happens only later, inside NetworkServer.VerifyConnection, after the verify-player round-trip succeeds:
// NetworkServer.cs:232
public static void VerifyConnection(long hostId, NetworkMessages.VerifyPlayer msg)
{
Client client = new Client(hostId, msg.OwnerConnectionId, msg.ClientId, msg.Name, msg.ClientConnectionMethod);
// ... blacklist / password / version checks ...
NetworkBase.AddClient(client); // ← only now in NetworkBase.Clients
Achievements.AssessWelcomeAboard();
JoiningClients.Enqueue(client);
client.SetState(ClientState.Queued);
if (!_processJoinQueueTaskRunning)
{
_processJoinQueueTaskRunning = true;
ProcessJoinQueue().Forget();
}
}
LaunchPadBooster transpiles VerifyPlayer.Process to redirect this VerifyConnection call through its own join-validate handshake (ModNetworking.cs:227-237). The handshake adds another round-trip; only after the LaunchPadBooster validate exchange completes does ConnectionState call NetworkServer.VerifyConnection(GetHostId(), VerifyPlayer):
// LaunchPadBooster.Networking.ConnectionState.cs:330-342
if (DoJoinValidateModList(joinValidateData.ModList) && DoJoinValidateModCustom(joinValidateData.ModCustom))
{
Status = ConnectionStatus.Ready;
if (NetworkManager.IsClient)
{
// client side: send our validate data back to server
SendJoinValidateData();
}
else
{
// server side: complete the join
NetworkServer.VerifyConnection(ModNetworking.GetHostId(), VerifyPlayer);
}
}
Then ProcessJoinQueue packages the world snapshot (PackageJoinData calls WorldManager.SerializeOnJoin, Thing.SerializeOnJoin for every Thing, etc.) and ships it to the joiner via NetworkChannel.PlayerJoin fragments.
Why SendAll from PlayerConnected misses the joiner¶
INetworkMessage.SendAll(0L) resolves to LaunchPadBooster.Networking.ModNetworkingExtensions -> ModNetworking.SendMessageAll, which iterates NetworkBase.Clients:
// LaunchPadBooster.Networking.ModNetworking.cs:913
internal unsafe static void SendMessageAll(INetworkMessage message, long excludeConnectionId)
{
TypeID typeID = messageRegistry.TypeIDFor(message.GetType());
sendAllList.Clear();
for (int i = 0; i < NetworkBase.Clients.Count; i++)
{
Client val = NetworkBase.Clients[i];
if ((int)val.state != 5 && val.connectionId != excludeConnectionId)
{
sendAllList.Add(GetConnection(val.connectionId));
}
}
if (sendAllList.Count == 0) return;
// ... encode + send ...
}
NetworkBase.Clients is populated only by NetworkBase.AddClient:
// NetworkBase.cs:38
public static void AddClient(Client client)
{
Clients.Add(client);
OnClientAdded();
}
And AddClient is called only from NetworkServer.VerifyConnection, which fires after the verify-handshake round-trip, well after PlayerConnected. So at the moment a Harmony postfix on NetworkManager.PlayerConnected runs and calls SendAll(0L), the joining client is NOT in NetworkBase.Clients. The broadcast goes only to peers that connected earlier; the new joiner is not among them.
Even if a message did reach the joiner before its connection was validated, the LaunchPadBooster receive path would drop it:
// LaunchPadBooster.Networking.ConnectionState.cs:603-621
internal void ReceiveMessage()
{
MessageHeader header;
ArraySegment<byte> arraySegment = ReadIncoming<MessageHeader>(out header);
TypeID typeID = header.TypeID;
if (Status != ConnectionStatus.Ready)
{
Debug.LogWarning((object)$"unexpected {typeID} message on connection {ConnectionID} when status is {Status}");
return;
}
// ... dispatch to message.Process(ConnectionID) ...
}
ConnectionStatus.Ready is set at the end of ReceiveJoinValidateData (ConnectionState.cs:332), which runs during the LaunchPadBooster validate handshake. That happens after both sides exchange validate data, which is after PlayerConnected on the host but before world snapshot transmission begins.
The correct hook: IJoinSuffixSerializer¶
LaunchPadBooster exposes IJoinSuffixSerializer as the documented hook for embedding mod state in the world-snapshot transmission:
// LaunchPadBooster.Networking.IJoinSuffixSerializer.cs
public interface IJoinSuffixSerializer
{
void SerializeJoinSuffix(RocketBinaryWriter writer);
void DeserializeJoinSuffix(RocketBinaryReader reader);
}
It is wired through IModNetworking.JoinSuffixSerializer { get; set; }. Mods set MOD.Networking.JoinSuffixSerializer = ... in OnAllModsLoaded (alongside JoinValidator).
Host side: LaunchPadBooster Harmony-prefix NetworkServer.PackageJoinData then calls WriteJoinSuffix(_joinWriter) to write each registered mod's section, framed by SectionedWriter per-mod hash:
// LaunchPadBooster.Networking.ModNetworking.cs:794
internal static void WriteJoinSuffix(RocketBinaryWriter writer)
{
SectionedWriter sectionedWriter = new SectionedWriter(writer);
foreach (ModNetworking instance in Instances)
{
IJoinSuffixSerializer joinSuffixSerializer = instance.JoinSuffixSerializer;
if (joinSuffixSerializer != null)
{
sectionedWriter.StartSection(instance.Hash);
joinSuffixSerializer.SerializeJoinSuffix(writer);
sectionedWriter.FinishSection();
}
}
sectionedWriter.Finish();
}
Client side: LaunchPadBooster transpiles NetworkClient.ProcessJoinData to replace the AtmosphericsManager.DeserializeOnJoin(reader) call with ReadJoinSuffix(reader). The replacement reads each mod's sectioned data, then internally calls AtmosphericsManager.DeserializeOnJoin to preserve vanilla.
The position in ProcessJoinData matters: the suffix runs after ProcessThings, so all Things are already deserialized and Thing.Find will resolve. Verbatim sequence:
// NetworkClient.cs:372
private static async UniTask ProcessJoinData()
{
// ...
GameManager.DeserializeGameTime(reader);
GameManager.DeserializeTerrainSeed(reader);
// ... LaunchPadBooster ReadJoinPrefix injected here ...
WorldManager.DeserializeOnJoin(reader);
await VoxelTerrain.LoadTerrain(...);
// ... Vein, OrbitalSimulation, TerraForming, VoxelTerrain ...
await StructureNetwork.DeserializeOnJoin(reader)...
await CableNetwork.DeserializeOnJoin(reader)...
// ... TraderContact, SpaceMap, Rocket, WorldLog, WorldObjectiveState ...
await ProcessThings(reader)... // ← every Thing deserialized
await RocketLog.DeserializeOnJoin(reader)...
RoomController.DeserializeOnJoin(reader);
await AtmosphericsManager.DeserializeOnJoin(reader)... // ← LaunchPadBooster transpile replaces this with ReadJoinSuffix; mod suffix sections then vanilla atmospherics
StructureNetwork.StructureNetworksOnFinishedLoad();
// ... GameManager.OnReadyToPlay, UpdateThingsOnGameStart (calls OnFinishedLoad on all Things) ...
UpdateHandshakeState(HandshakeType.ClientReady);
}
Net: IJoinSuffixSerializer.DeserializeJoinSuffix runs after ProcessThings and before Thing.OnFinishedLoad. Thing.Find(referenceId) resolves at this point.
IJoinPrefixSerializer is the symmetric option that runs BEFORE GameManager.DeserializeGameTime (LaunchPadBooster transpile inserts it there). Use prefix when the mod data must precede the world snapshot; use suffix when the mod data must follow it (i.e. when Things must already exist for resolution).
Practical implications for the StationeersPlus monorepo¶
Two existing PowerTransmitterPlus sync flows broadcast on NetworkManager.PlayerConnected postfix:
DistanceConfigSync.BroadcastIfHost()->DistanceConfigMessage.SendAll(0L)(since v1.0.0)BeamVisualConfigSync.BroadcastIfHost()->BeamVisualConfigMessage.SendAll(0L)(since v1.1.0)
By the source evidence above, neither broadcast reaches the joining client. Existing clients receive a redundant rebroadcast of values they already have; the new joiner falls back to its local BepInEx config values until the host changes a setting later (which triggers a SettingChanged rebroadcast that then catches the joiner because they're now in NetworkBase.Clients).
For PowerTransmitterPlus this is mostly invisible because the values are display-only on clients (the simulation runs server-authoritatively with the host's values). For the v1.6.0 auto-aim cache delivery to a joining client, the same pattern would be a real defect: the joining client's MicrowaveAutoAimTarget reads would return 0 until someone re-writes the LogicType. The fix is to switch that path to IJoinSuffixSerializer.
Cache survival post-join: delta-state setter writes can wipe per-dish caches¶
IJoinSuffixSerializer populates a per-dish cache on the joining client correctly at join time, but vanilla's per-tick delta-state replication can wipe specific entries afterwards. Relevant decompile:
// WirelessPower.cs:143
public override void ProcessUpdate(RocketBinaryReader reader, ushort networkUpdateType)
{
base.ProcessUpdate(reader, networkUpdateType);
if (Thing.IsNetworkUpdateRequired(256u, networkUpdateType))
{
float num = reader.ReadFloatHalf();
float num2 = reader.ReadFloatHalf();
if (RotatableBehaviour != null)
{
RotatableBehaviour.TargetVertical = num;
RotatableBehaviour.TargetHorizontal = num2;
}
}
}
// Thing.cs:5594 (SerializeDeltaState body)
if ((bool)thing && thing.ReferenceId != 0L && !thing.IsBeingDestroyed && thing.IsNetworkUpdate())
{
ushort networkUpdateType = thing.NetworkUpdateFlags;
...
thing.BuildUpdate(writer, networkUpdateType);
thing.NetworkUpdateFlags = 0;
}
Mechanism:
- The host's
RotatableBehaviour.TargetHorizontal/TargetVerticalsetters (RotatableBehaviour.cs servo state) setNetworkUpdateFlags |= 256. AfterBuildUpdateruns on the host's next tick, the flag clears. So a stable host (no setter writes) emits no delta and the joiner's cache survives indefinitely. - When the host's auto-aim target changes (player adjust, IC10
s d0 MicrowaveAutoAimTarget, IC10s d0 Horizontal, tablet, etc.), flag 256 is set on the host. Next tick the delta is emitted. The client'sProcessUpdatewrites the targets through the setters, and any Harmony postfix on those setters that does NOT consult a cross-peer suppression flag will fire on the client even though the write is server-driven, not local input.
For PowerTransmitterPlus's RotatableTargetHorizontalResetPatch / RotatableTargetVerticalResetPatch (AutoAimPatches.cs:244-270), this means: the host's HandleWrite correctly sets [ThreadStatic] _suppressReset to prevent the reset postfix from firing during its own setter writes, but the client has no equivalent because the client never enters HandleWrite (WirelessPower.SetLogicValue is server-authoritative). So delta-state-driven setter writes on the client clear the per-dish cache that IJoinSuffixSerializer had populated.
Net effect for v1.6.1's auto-aim cache:
- Stable host scenario: cache survives indefinitely on the joiner. IC10 reads return correct ReferenceId.
- Dynamic host scenario (any setter write on a dish): the joiner's cache for that specific dish is wiped on the next tick, IC10 reads revert to 0 until the host writes again.
Vanilla's delta carries only TargetHorizontal and TargetVertical (half-precision floats); it does not carry the auto-aim target's ReferenceId, so the joiner cannot re-derive the target id from the delta alone.
Two fixes:
-
Wrap
WirelessPower.ProcessUpdatewith a Harmony Prefix/Postfix pair on the client that toggles_suppressReset = true/falsearound the call. Prevents the cache from being cleared, but the value stays at whatever was last populated (becomes stale when the host changes target). IC10 scripts read a stale id rather than 0; arguably worse than current behaviour because 0 honestly signals "I don't know." -
Piggyback the target ReferenceId onto each
WirelessPower's delta-state via an unusedNetworkUpdateFlagsbit. Set the bit inHandleWritealongside the standard 256 flag; extendBuildUpdatePostfix to write the cached target id when the bit is set; extendProcessUpdatePostfix to read it and callAutoAimState.RestoreCache(__instance, targetId). Closes the dynamic-case gap end-to-end. The pattern is documented inResearch/Protocols/EquipmentPlusNetworking.md(sensor-lens0x4000flag).
Option 2 is the canonical Stationeers way and the recommended path for any future v1.7+ that needs full target-id sync.
Verification history¶
- 2026-04-26: page created. Originally fabricated by a sub-agent (Explore subagent that wrote a research page via Bash redirection without any actual decompile reads, citing only mod-source files in this repo as "evidence"). Replaced wholesale with verbatim decompile excerpts from
NetworkManager.cs,NetworkServer.cs,NetworkBase.cs,NetworkClient.cs, and LaunchPadBooster'sModNetworking.csandConnectionState.cs. Game version 0.2.6228.27061. Decompile produced viailspycmd -p Assembly-CSharp.dllandilspycmd -p LaunchPadBooster.dll. - 2026-04-26: added "Cache survival post-join: delta-state setter writes can wipe per-dish caches" section. Sourced from
WirelessPower.csProcessUpdate decompile andThing.csSerializeDeltaState decompile (NetworkUpdateFlags reset to 0 after each BuildUpdate). Documents that v1.6.1's IJoinSuffixSerializer fix is sufficient for static-host scenarios but the per-dish cache erodes on the joiner whenever the host writes a target. The asymmetry comes from_suppressResetbeing a[ThreadStatic]flag set only insideHandleWrite(server-authoritative), with no equivalent on the client side. Game version 0.2.6228.27061.
Open questions¶
None at creation.