Skip to content

NetworkManager

Stationeers multiplayer networking manager for client-hosted sessions. Uses RakNet UDP sockets with fallback behavior and Steam P2P capability.

NIC Selection and IP Binding

When a client starts hosting a multiplayer game, NetworkManager.StartServer(ushort port) (line 255734) determines which local IP to bind:

  1. Manual override first: If Settings.CurrentData.LocalIpAddress is non-empty, use it directly (line 255743-255748).
  2. Auto-detection fallback: If not set, call GetIPv4Address() (line 255752).

The auto-detection method GetIPv4Address() (line 255705-255732) enumerates all RakNet-reported local IPs in reverse order (line 255712: for (int num2 = (int)(numberOfAddresses - 1); num2 >= 0; num2--)). It returns the last non-loopback IPv4 address found. This reverse enumeration is the root cause of incorrect NIC selection when a machine has both a physical NIC and virtual adapters (e.g., Hyper-V Default Switch): RakNet's adapter enumeration order may place the virtual adapter last, and the reverse loop makes it first to be returned.

Code excerpt (line 255705-255732):

public static string GetIPv4Address()
{
    uint numberOfAddresses = Instance.rakNet.GetNumberOfAddresses();
    if (numberOfAddresses != 0)
    {
        ConsoleWindow.Print("Found Ipv4 addresses");
        int num = -1;
        for (int num2 = (int)(numberOfAddresses - 1); num2 >= 0; num2--)
        {
            string localIP = Instance.rakNet.GetLocalIP((uint)num2);
            ConsoleWindow.Print(localIP);
            if (IPAddress.TryParse(localIP, out var address))
            {
                byte[] addressBytes = address.GetAddressBytes();
                if (address.AddressFamily == AddressFamily.InterNetwork && addressBytes[0] != 127)
                {
                    num = num2;
                }
            }
        }
        if (num == -1)
        {
            throw new System.Exception("No network adapters with an IPv4 address in the system!");
        }
        return Instance.rakNet.GetLocalIP((uint)num);
    }
    throw new System.Exception("No network adapters with an IPv4 address in the system!");
}

The binding and socket creation happens at line 255755-255763:

SocketDescriptor socketDescriptor = new SocketDescriptor(text2, port);
Span<SocketDescriptor> socketDescriptors = stackalloc SocketDescriptor[1] { socketDescriptor };
StartupResult startupResult = Instance.rakNet.Startup((uint)MaxConnections, socketDescriptors, 2);
if (startupResult != StartupResult.RaknetStarted)
{
    ConsoleWindow.PrintAction("Hosting failed. Attempting fallback behaviour");
    SocketDescriptor socketDescriptor2 = new SocketDescriptor("", port);
    Span<SocketDescriptor> socketDescriptors2 = stackalloc SocketDescriptor[1] { socketDescriptor2 };
    startupResult = Instance.rakNet.Startup((uint)MaxConnections, socketDescriptors2, 2);
}

Settings.LocalIpAddress Configuration

Settings.SettingData (line 248235) declares LocalIpAddress as an XML-serialized field (line 248482):

[XmlElement]
public string LocalIpAddress = string.Empty;

This field is serialized to/from the player's settings XML file and can be edited by the user. It is not populated from command-line arguments; it is read from the persistent settings store only.

Command-Line Argument Handling

The game parses launch arguments in CommandLine.ProcessOnLaunch() (line 95067-95076) via Environment.GetCommandLineArgs() and a CommandLineArgs property (line 94938). The TryGetArg method (line 95079-95096) retrieves argument values.

Exhaustive scan of the decompiled assembly shows no command-line flag that accepts or sets an IP address for multiplayer binding. The only launch-time network-related flag found is -nodiscord / --nodiscord (line 177514) which disables Discord integration, not networking.

The game's command registry at lines 94942-95039 includes console commands (executed in-game) but none that parse -bindaddress, -bind, -ip, -host, -localip, or similar. The console command list (starting at line 94944) includes upnp, network, netconfig, and various server / client management commands, but none expose IP binding via console arguments either.

Transport Type

Multiplayer uses two parallel transports:

  1. RakNet UDP (default): All game-state replication. Bound to the selected local IP per GetIPv4Address() or LocalIpAddress setting.
  2. Steam P2P (optional, controlled by Settings.CurrentData.UseSteamP2P): Supplemental connections via SteamNetworking API (line 255605-255623, 255961-255963). Does not depend on local IP binding; routed through Steam's relay infrastructure.

The RakNet transport is the one that exhibits the NIC-selection bug. Steam P2P does not suffer from it because Valve's relay network does not depend on the host's local adapter enumeration order.

Inbound messages are processed on the Unity main thread, on both builds

Everything an IMessageProcessable.Process(hostId) does, on a listen host and on a headless dedicated server alike, runs on the Unity main thread inside GameManager.Update. The chain, read on the dedicated-server build at 0.2.6428.27798:

public override void ManagerUpdate()                                  // NetworkManager, :265258
{
    base.ManagerUpdate();
    if (NetworkState != NetworkState.Offline)
    {
        Instance.EnsureRakNet();
        while (ReceiveEvents())
        {
        }
        DeferredMessageQueue.TickExpire();
        CheckConnection();
private static void HandleGeneralTraffic(long hostId, int incReceivedSize)   // :265738
{
    using MemoryStream memoryStream = new MemoryStream(incReceivedSize);
    memoryStream.Write(Buffer, 0, incReceivedSize);
    memoryStream.Seek(0L, SeekOrigin.Begin);
    Array.Clear(Buffer, 0, Buffer.Length);
    RocketBinaryReader rocketBinaryReader = new RocketBinaryReader(memoryStream);
    NetworkBase.DeserializeReceivedData(hostId, rocketBinaryReader);
    rocketBinaryReader.Dispose();
    rocketBinaryReader.Close();
}
public static void DeserializeReceivedData(long hostId, RocketBinaryReader reader)   // NetworkBase, :39287
{
    Type type = reader.ReadMessageType();
    IMessageSerialisable orAdd = _messageInstances.GetOrAdd(type, ResolveMessageInstance);
    try
    {
        orAdd.Deserialize(reader);
    }
    catch (EndOfStreamException exception)
    {
        UnityEngine.Debug.LogException(exception);
        UnityEngine.Debug.LogError($"Message: ({type}) {orAdd}");
    }
    if (orAdd is IMessageProcessable messageProcessable)
    {
        if (!Assets.Scripts.Networking.NetworkManager.IsServer && !(orAdd is NetworkMessages.Handshake) && ... )
        {
            ConsoleWindow.PrintError($"Messages should only be processed on the server. Message: ({type}) {orAdd}");
        }
        messageProcessable.Process(hostId);
    }
}

ManagerUpdate is called from the per-frame manager loop at the end of GameManager.Update(), which is the Unity main thread and runs at about 24 Hz on a headless -batchmode -nographics server (measured; see SimulationTickDriverHooks, "GameManager.Update manager loop" and "the Unity player loop runs at ~24 Hz whether or not the world is paused"). That loop sits outside the if (!WorldManager.IsGamePaused) block, so message processing keeps running on a parked server with nobody connected.

Three consequences worth stating, because each is a thing that has been guessed at:

  • GameManager.IsThread is false inside a message Process. The property is inverted from its name (true means NOT the main thread), so any mod code that gates on it, including Patterns/Console/PlayerMessage.cs's console leg and its Broadcast, runs rather than being skipped, on a dedicated server exactly as on a client. A stroke that reaches a server over the wire can therefore print to the console and send a ChatMessage.
  • Unity API calls are legal there. No marshal is needed for work driven by an inbound message, unlike the simulation *Tick methods, which GameManager.GameTick runs on a rotating ThreadPool worker.
  • It is a different pump from the simulation. A message is handled every frame; a simulation tick may never happen at all on a parked server (GameTickCount stayed at 0 over 287 s with no client connected). Anything that must observe simulated state after handling a message has to wait for a tick that a parked server will not deliver.

Verification history

  • 2026-09-24: added "Inbound messages are processed on the Unity main thread, on both builds" from the 0.2.6428.27798 DEDICATED-SERVER decompile, while settling whether a paint stroke arriving at a headless server can reach a mod's player-facing console output (it can: GameManager.IsThread is false in a message Process). Additive; this page carried nothing about the receive path before, and nothing on it was contradicted. Cross-checked against the measured main-thread and ~24 Hz claims on ../GameSystems/SimulationTickDriverHooks.md rather than re-measuring them. Frontmatter moved to 0.2.6428.27798 for the new section; the four pre-existing sections keep their 0.2.6228.27061 stamps and have not been re-read against the current build.
  • 2026-06-03: initial page creation from Assembly-CSharp.decompiled.cs (version 0.2.6228.27061)

Open questions

  • Why does GetIPv4Address() iterate RakNet's address list in reverse? Is this intentional (oldest adapter preferred) or a bug? Original commit history not available in the decompiled artifact.
  • What is RakNet's adapter enumeration order on Windows with Hyper-V virtual switches? Does it correlate with the order in ipconfig or the routing table?