Skip to content

Player identity and the Steam session

A Stationeers client process carries two identities that are easy to conflate:

  • Its Steam session: the account of the Steam client the process attached to. Steam P2P, lobbies, rich presence, stats, the Workshop and DLC ownership use it.
  • Its multiplayer identity: NetworkManager.Cookie.ClientId and .Username, read from PlayerCookie-v2.xml. The join handshake, the server's client list, bans, character ownership and saves use it.

The cookie is seeded from the Steam session once, when it is created, which is why the two normally hold the same number. After that they are independent. This page traces both, lists what uses which, records what happens when several client processes share one Steam login, and states when one login stops being enough.

Line numbers refer to the 0.2.6428.27798 decompiles under .work/decomp/0.2.6428.27798/ unless a section says otherwise.

Summary

  • Every client process initialises Steam itself with SteamClient.Init(544550u) and never calls RestartAppIfNecessary, so a process started directly, not from the Steam library, attaches to whichever account the running Steam client is logged in to. Several processes can do that at once, and the Steam client tracks all of them. Two such processes, read from memory on 2026-09-21, held the same SteamClient.SteamId.
  • The multiplayer identity is the cookie. It is honoured as read, sent self-reported in VerifyPlayer, and checked by the server only against the blacklist, the password and the game version. Nothing on the join path consults Steam.
  • So on one Steam login, N client processes are N distinct players if, and only if, each presents its own ClientId. That is a requirement, not an option: the cookie file is per Windows user, every unmodified process presents the same id, and nothing rejects the duplicate.
  • A second Steam login matters only for what Steam keys on the account. See "When one Steam login is not enough".

Steam initialisation in a client process

GameManager.Awake (210494) calls NetworkManager.Init(TransportType.Rocket) inside a try/catch that only logs (210502-210509). Init (279719) creates a MetaServerTransport and calls InitClient; MetaServerTransport.InitClient (287032) creates a SteamTransport and calls its InitClient (287346):

public void InitClient()
{
    if (GameManager.IsBatchMode || (Application.isEditor && !InitialiseSteamInEditor))
    {
        return;
    }
    try
    {
        SteamClient.Init(544550u);
        SteamFriends.OnGameRichPresenceJoinRequested += RichPresenceJoinRequested;
        SteamServer.OnSteamServersConnected += delegate
        {
            UnityEngine.Debug.Log($"Steam server connected: {SteamClient.IsValid}, IP: {SteamServer.PublicIp}");
        };
        SteamServer.OnSteamServersDisconnected += delegate(Steamworks.Result res)
        {
            UnityEngine.Debug.Log($"Steam server disconnected. Reason {res}");
        };
        SteamServer.OnSteamServerConnectFailure += delegate(Steamworks.Result res, bool stillTrying)
        {
            UnityEngine.Debug.Log($"Steam server failed to connect. Reason {res}, stillTrying: {stillTrying}");
        };
        SteamUserStats.OnUserStatsReceived += OnUserStatsReceived;
    }
    catch (System.Exception ex)
    {
        ConsoleWindow.Print("Error Initialising Steam: " + ex.Message, ConsoleColor.Red);
    }
}

Steamworks.SteamClient in Facepunch.Steamworks.Win64.dll (Facepunch decompile 11353-11380):

public static bool IsValid => initialized;

public static bool IsLoggedOn => SteamUser.Internal.BLoggedOn();

public static SteamId SteamId => SteamUser.Internal.GetSteamID();

public static string Name => SteamFriends.Internal.GetPersonaName();
...
public static void Init(uint appid, bool asyncCallbacks = true)
{
    if (initialized)
    {
        throw new Exception("Calling SteamClient.Init but is already initialized");
    }
    Environment.SetEnvironmentVariable("SteamAppId", appid.ToString());
    Environment.SetEnvironmentVariable("SteamGameId", appid.ToString());
    if (!SteamAPI.Init())
    {
        throw new Exception("SteamApi_Init returned false. Steam isn't running, couldn't find Steam, AppId is ureleased, Don't own AppId.");
    }
    AppId = appid;
    initialized = true;
    ...

Three consequences, all read from the code above:

  • No steam_appid.txt is needed. Init writes the app id into the process environment (SteamAppId, SteamGameId) before SteamAPI.Init(). No such file exists in the game install root or in either TestRig instance root (checked 2026-09-21).
  • Nothing relaunches the game through Steam. RestartAppIfNecessary is declared in the Facepunch assembly (lines 402, 420, 11447) and has no call site in Assembly-CSharp or in StationeersLaunchPad.
  • SteamId and Name belong to the Steam client, not the process. They read SteamUser.GetSteamID() and SteamFriends.GetPersonaName() through the Steam client the process attached to, which is logged in to one account.

StationeersLaunchPad initialises Steam first on a client. LaunchPadConfig.StageInitializing (StationeersLaunchPad decompile 1759) runs, unless SteamDisabled is already set by the DisableSteam setting or a server platform:

if (!SteamDisabled)
{
    try
    {
        SteamPatches.GetMetaServerTransport().InitClient();
        SteamDisabled = !SteamClient.IsValid;
    }
    catch (Exception ex2)
    {
        Logger.Global.LogError("failed to initialize steam: " + ex2.Message);
        Logger.Global.LogError("workshop mods will not be loaded");
        Logger.Global.LogError("turn on DisableSteam in LaunchPad Configuration to hide this message");
        StopAutoLoad();
        SteamDisabled = true;
    }

and SteamPatches (2353) makes the game's own later call a no-op: a transpiler swaps the new MetaServerTransport() inside NetworkManager.Init for the shared GetMetaServerTransport() instance, and two prefixes skip a second initialisation:

[HarmonyPatch(typeof(SteamClient), "Init")]
[HarmonyPrefix]
private static bool SteamClient_Init(uint appid, bool asyncCallbacks)
{
    return !SteamClient.IsValid;
}
[HarmonyPatch(typeof(MetaServerTransport), "InitClient")]
[HarmonyPrefix]
private static bool MetaServerTransport_InitClient(MetaServerTransport __instance)
{
    return !__instance.IsInitialised;
}

Batch mode never initialises Steam. InitClient returns at the GameManager.IsBatchMode check, so the dedicated server, and a client started with -batchmode, have no Steam session at all.

A failed initialisation is caught and the game carries on. Facepunch throws when SteamAPI.Init() returns false, SteamTransport.InitClient prints Error Initialising Steam: <message>, and SteamClient.IsValid stays false. What that changes:

Consumer With Steam not initialised
GameManager.GetSteamId() (209572) returns 0uL
DLCManager.FetchOwnershipFromSteam (197724) every IsDlcInstalled answers false, because both transports check first (MetaServerTransport 287049 tests _steamTransport.IsInitialised, SteamTransport 287420 tests IsInitialised), so the process owns no DLC
PlayerCookie.CreateNewCookie (194987) only when no usable cookie exists: ClientId = GenerateUniqueClientId() (a random value from rand.Next(0, int.MaxValue) not already in NetworkBase.Clients, 279906) and Username = "Recruit"
StationeersLaunchPad SteamDisabled = !SteamClient.IsValid; WorkshopModSource.ListMods (8955-8960) then lists the Workshop mods already in modconfig.xml when RetainWorkshopMods is true (its default, bound at 560) and none otherwise
Rich presence, stats, lobbies, Workshop queries skipped behind SteamClient.IsValid checks

The multiplayer identity is the PlayerCookie

NetworkManager.Init (279719-279731) loads the cookie straight after the transport:

public static void Init(TransportType transportType)
{
    if (transportType == TransportType.Rocket)
    {
        MetaServerTransport currentTransport = new MetaServerTransport();
        CurrentTransport = currentTransport;
        CurrentTransport.InitClient();
        Cookie = ((!GameManager.IsBatchMode) ? PlayerCookie.Load() : null);
        Config = NetConfig.Load();
        FetchPublicIp().Forget();
        return;
    }
    throw new ArgumentOutOfRangeException("transportType", transportType, null);

and everything else reads the identity through it (278354-278360):

public static PlayerCookie Cookie { get; private set; }

public static NetConfig Config { get; private set; }

public static ulong LocalClientId => Cookie?.ClientId ?? 0;

public static string Username => Cookie?.Username ?? string.Empty;

Networking.Servers.PlayerCookie (194852) stores it per Windows user:

private static string PATH_VERSIONED => string.Format("{0}/{1}-v{2}.xml", Application.persistentDataPath, "PlayerCookie", (ushort)2);

Load() (194939) returns whatever is on disk when it is usable, and only otherwise asks Steam:

public static PlayerCookie Load()
{
    PlayerCookie playerCookie = null;
    if (HasVersionedCookie())
    {
        PlayerCookieSaveDataV2 playerCookieSaveDataV = XmlSerialization.LoadOrNull<PlayerCookieSaveDataV2>(PATH_VERSIONED);
        if (playerCookieSaveDataV != null)
        {
            playerCookie = new PlayerCookie(playerCookieSaveDataV);
        }
    }
    else
    {
        PlayerCookieSaveDataV1 playerCookieSaveDataV2 = XmlSerialization.LoadOrNull<PlayerCookieSaveDataV1>(PATH_NONVERSIONED);
        if (playerCookieSaveDataV2 != null)
        {
            playerCookie = new PlayerCookie(playerCookieSaveDataV2);
        }
    }
    if (playerCookie?.Username == "Recruit")
    {
        playerCookie.Username = (SteamClient.IsValid ? SteamClient.Name : "Recruit");
        playerCookie.Save();
    }
    if (playerCookie != null && playerCookie.Version == 2 && playerCookie.ClientId != 0L)
    {
        return playerCookie;
    }
    PlayerCookie playerCookie2 = CreateNewCookie();
    playerCookie2.Save();
    return playerCookie2;
}
private static PlayerCookie CreateNewCookie()
{
    return new PlayerCookie
    {
        Version = 2,
        ClientId = (SteamClient.IsValid ? ((ulong)SteamClient.SteamId) : Assets.Scripts.Networking.NetworkManager.GenerateUniqueClientId()),
        Username = (SteamClient.IsValid ? SteamClient.Name : "Recruit")
    };
}

What follows from it:

  • Every process a Windows user starts reads the same file. persistentDataPath is not separated by a copied install or an edited app.info (measured at 0.2.6403.27689, TestRig/RESEARCH.md "What is not separable"; the strings that build the path are on DisplayResolutionAndPlayerPrefs).
  • Steam is consulted only when there is no usable cookie. A Version 2 cookie with a non-zero ClientId is returned as read. A player's ClientId normally equals their SteamID64 only because their cookie was created from it once. On the development machine the cookie was created 2023-07-01 and its ClientId equals the SteamID64 of the Steam client's ActiveUser (compared on 2026-09-21 without recording the value).
  • The identity is writable in memory. Version, ClientId and Username are auto-properties with public setters, so a plugin can change a running process's identity with one assignment.
  • Save() (194972) writes the whole cookie back over the shared file. Its call sites:
Line Trigger
194961 Load(), when the stored name is "Recruit" and gets replaced with the Steam persona name
194968 Load(), persisting a freshly created cookie
210620 dismissing the old-save popup with "Don't show again"
210629 dismissing the major-update popup with "Don't show again"
292198 opening the in-game menu while a world is running (it stores UI prefs and discovered points of interest in the cookie)

A process that changes ClientId in memory while sharing that file has to stop Save(), or the next Esc in a world writes its id into the real player's cookie.

  • Batch mode has no cookie. Cookie is null, so LocalClientId is 0 on the dedicated server.

What the ClientId and Username are used for

The join handshake. When the server asks, the client answers with its cookie values (NetworkMessages.VerifyPlayerRequest.Process, 285217-285231):

public override void Process(long hostId)
{
    if (!NetworkManager.IsClient)
    {
        return;
    }
    ConsoleWindow.Print($"id received by server {NetworkManager.LocalClientId}");
    NetworkClient.StopConnectionTimer();
    VerifyPlayer msg = new VerifyPlayer
    {
        OwnerConnectionId = ClientConnectionID,
        ClientConnectionMethod = ClientConnectionMethod,
        ClientId = NetworkManager.LocalClientId,
        Name = NetworkManager.Username,
        Version = GameManager.GetGameVersion()
    };

NetworkMessages.VerifyPlayer (285166) serialises exactly OwnerConnectionId, ClientId, Name, Password, Version and ClientConnectionMethod. There is no Steam ticket and no separate Steam id field.

The server accept. NetworkServer.VerifyConnection (219435) builds the server's Client from those fields and checks three things:

public static void VerifyConnection(long hostId, NetworkMessages.VerifyPlayer msg)
{
    Client client = new Client(hostId, msg.OwnerConnectionId, msg.ClientId, msg.Name, msg.ClientConnectionMethod);
    ConsoleWindow.Print($"Verifying {msg.ClientConnectionMethod.ToString()} connection for player {msg.ClientId}");
    if (Blacklist.Any((BlacklistedClient x) => x.Id == msg.ClientId))
    {
        HandleBlacklisting(msg, client);
        return;
    }
    if (!string.IsNullOrEmpty(Settings.CurrentData.ServerPassword) && Settings.CurrentData.ServerPassword != msg.Password)
    {
        HandleIncorrectPassword(client);
        return;
    }
    if (GameManager.GetGameVersion() != msg.Version)
    {
        HandleIncorrectVersion(client, msg);
        return;
    }
    NetworkBase.AddClient(client);

Steam authentication exists and is not wired in. SteamTransport.Authenticate(ulong clientId) (287500) is the only SteamUser.BeginAuthSession and SteamServer.UserHasLicenseForApp call in the assembly, and a search for Authenticate( finds only that definition. The deployed LaunchPadBooster.dll and StationeersLaunchPad.dll contain neither BeginAuthSession nor GetAuthSessionTicket (binary string scan, 2026-09-21).

Everything downstream keys on the self-reported id:

Use Member Line
The server's per-connection record new Client(hostId, msg.OwnerConnectionId, msg.ClientId, msg.Name, msg.ClientConnectionMethod) 219437
Bans Blacklist.Any((BlacklistedClient x) => x.Id == msg.ClientId) 219439
The name other players see Name = NetworkManager.Username in the handshake 285230
Which body a player controls Brain.PlayerBrains, a Dictionary<ulong, Brain>; RegisterBrain(ulong steamId) does PlayerBrains[steamId] = this with no duplicate check 346892, 347283-347286
Spawn and start-location bookkeeping GameManager.AddClientInfo, ClientInfo.TryAdd(clientId, info) 209531-209533
Ownership written into saves BrainSaveData.ClientSteamId, restored as ClientId = brainSaveData.ClientSteamId; OwnerSteamId, restored into OwnerClientId 346885, 347106, 329564, 327253
Avatars SteamTransport.GetAvatarAsync(ulong clientId, AvatarSize size) asks SteamFriends for that id 287468

The save fields and the RegisterBrain parameter are named for Steam, but they hold whatever ClientId the player presented, and a non-Steam id is saved and restored like any other. Pages that call this key a "Steam id" (PlayerIdentityAcrossRejoin, Client) mean this value.

Two processes presenting the same ClientId resolve onto one body: the second RegisterBrain silently replaces the first entry in PlayerBrains. A listen host consumes an id of its own (ListenHost).

What keys on the Steam account instead

Feature Where Keyed on
Steam P2P transport NetworkManager.StartClient(SteamId hostSteamId) (279141); ProcessP2PSessionRequest (278843); SteamNetworking.SendP2PPacket, AcceptP2PSessionWithUser, CloseP2PSessionWithUser the peer's SteamID, which is the only address and, on the host, also the connection id
Steam lobby StartServer (278969) sets _hostSteamId = GameManager.GetSteamId() and calls steamLobby.HostLobby(_hostSteamId, Settings.CurrentData.ServerMaxPlayers) without awaiting it; SteamLobby.HostLobby (195192) creates a lobby, SetPublic(), marks it joinable and calls SetGameServer(hostId); a lobby join ends in StartClient(steamId) over P2P the host's account
Rich presence GameManager.SetSteamRichPresence, SteamTransport.SetRichPresence, the connect string the account
Achievements and stats SteamUserStats the account
Workshop Query.Items.WithTag(...).WhereUserSubscribed() (287641); IsOwner = (ulong)item.Owner.Id == (ulong)SteamClient.SteamId (287264); a delete only when the account owns the item (287628) the account
DLC ownership DLCManager.FetchOwnershipFromSteam (197724) asks CurrentTransport.IsDlcInstalled(<DLC app id>) for each DLC, once, from DLCManager.Initialize() in GameManager.Start() (210569) the account, sampled once per process at boot

Settings.SettingData.UseSteamP2P defaults to true (271221). It decides whether ProcessP2PSessionRequest accepts, whether the server browser tries P2P first (OnItemCommitted, 47879: P2P when the selected session has a valid HostSteamId, falling back to address:port if that fails), and whether GameSessionConfig advertises the host's SteamID. It does not affect Direct Connect. JoinClientFromMenu (218772) takes the P2P branch only for a single 17-character token, and anything of the form address:port goes to RakNet:

public void JoinClientFromMenu(string ipPort)
{
    ClientPreJoin();
    string[] array = ipPort.Split(':');
    if (array.Length == 1 && array[0].Length == 17)
    {
        JoinWithSteamP2P(ulong.Parse(ipPort));
        return;
    }

ProcessP2PSessionRequest in full, because its duplicate handling is what matters for several processes on one account (278843-278866):

private void ProcessP2PSessionRequest(SteamId steamId)
{
    if (Settings.CurrentData.UseSteamP2P && steamId.IsValid && (NetworkRole == NetworkRole.None || NetworkRole == NetworkRole.Server))
    {
        long value = (long)steamId.Value;
        if (Client.Find(value) != null)
        {
            ConsoleWindow.PrintError("P2P Connection request received from Client already in the list of Clients- Removing them from the list so they can rejoin");
            NetworkServer.ClientDisconnected(value);
        }
        ConsoleWindow.Print("Steam P2P request accepted from Steam ID " + steamId.ToString());
        bHasSteamP2PConnections = true;
        NetworkRole = NetworkRole.Server;
        NetworkState = NetworkState.Online;
        SendDataToSteamP2PConnection(steamId, null, 139);
        SteamNetworking.AcceptP2PSessionWithUser(steamId);
        PlayerConnected((long)steamId.Value, ConnectionMethod.FacepunchSteamP2P);
    }
    else
    {
        ConsoleWindow.PrintError("Steam request received but rejected - Steam P2P requests are disabled in settings or this user is hosting");
    }
}

The connection id is the SteamID, and a second request from a SteamID already in the client list disconnects the first. CloseP2PConnectionServer (278417) closes a P2P client with SteamNetworking.CloseP2PSessionWithUser(clientToDisconnect.ClientId), so on the P2P path the game also assumes ClientId is the SteamID.

Two Steam paths that look relevant are dead in this build: SendToSteamCloud() and RemoveFromSteamCloud() (48577, 48590) have no call sites, and DLCManager.GrantFullOwnership() (197719) has no call site, so a process's DLC bits come from Steam and from nowhere else in vanilla code.

Several client processes on one Steam login

Runtime evidence from 2026-09-21, from the TestRig client half: two instances named hostie and joiner, each a hard-linked copy of the game started directly with CreateProcessW (see "How the test rig gives each instance its own identity"). The machine runs one steam.exe (up since 2026-09-16, with its last logon on 2026-09-17 and none on 2026-09-20 or 2026-09-21 in logs\connection_log.txt), and the Steam client's config\loginusers.vdf lists exactly one account, the one named by ActiveUser under HKCU\Software\Valve\Steam\ActiveProcess.

Steam tracked both processes at the same time. The Steam client's own logs\gameprocess_log.txt, local time (UTC+2), paths replaced with placeholders:

[2026-09-21 03:58:42] AppID 544550 adding PID 108272 as a tracked process "<instances root>\hostie\rocketstation.exe -logFile <repo>\TestRig\ClientRig\data\hostie\logs\unity-20260921-015837.log -settingspath <repo>\TestRig\ClientRig\data\hostie\setting.xml -screen-width 800 -screen-height 600 -screen-fullscreen 0"
[2026-09-21 03:58:42] SSGL: InternalUpdateClientGame indicates change to games list
[2026-09-21 03:58:42] AppID 544550 adding PID 79756 as a tracked process "<instances root>\joiner\rocketstation.exe -logFile <repo>\TestRig\ClientRig\data\joiner\logs\unity-20260921-015837.log -settingspath <repo>\TestRig\ClientRig\data\joiner\setting.xml -screen-width 800 -screen-height 600 -screen-fullscreen 0"
...
[2026-09-21 04:01:22] AppID 544550 no longer tracking PID 79756, exit code 0
[2026-09-21 04:01:22] AppID 544550 no longer tracking PID 108272, exit code 0
[2026-09-21 04:01:22] Remove 544550 from running list

The PIDs are the rig's: the launcher recorded PID 108272 for hostie and 79756 for joiner, both started at 01:58:37Z, and each instance's GET /status reported its own number as foreground.ownPid. Counting every such overlap in that log: 82 pairs of a hostie and a joiner process tracked at the same time, on 8 days from 2026-08-09 to 2026-09-21 (13 of them on 2026-09-21), plus 3 pairs from two earlier test copies on 2026-07-30. No refusal, error or single-instance message sits around any of them. Nothing launched them through Steam: between 03:00 and 04:59 local, the Steam client's logs\console_log.txt has 21 Game process added and 13 Game process updated lines for AppID 544550 and no GameAction ... LaunchApp entry.

The Steam client keeps one game record per app, and the second process takes it over. The same console_log.txt:

[2026-09-21 03:58:42] Game process added : AppID 544550 "<instances root>\hostie\rocketstation.exe -logFile <log> -settingspath <settings> -screen-width 800 -s", ProcID 108272, IP 0.0.0.0:0
[2026-09-21 03:58:42] Game process updated : AppID 544550 "<instances root>\hostie\rocketstation.exe -logFile <log> -settingspath <settings> -screen-width 800 -s", ProcID 79756, IP 0.0.0.0:0
[2026-09-21 04:01:22] Game process removed: AppID 544550 "<instances root>\hostie\rocketstation.exe -logFile <log> -settingspath <settings> -screen-width 800 -s", ProcID 79756

The first process adds the record. The second arrives as Game process updated, keeping the first process's command line and carrying its own ProcID. One removed line closes it. So both processes are one "running game" to the Steam client.

Both processes had a working Steam session. At 02:00:28Z, both at the main menu, GET /dlc on each returned ownedMask 256 (MetallicPaints), overridden false and gameInitialized true. The only writer of those bits is FetchOwnershipFromSteam, which gets false for every DLC unless SteamClient.IsValid, so each process had initialised Steam against the account while the other was running. All four runs of the two DLC checks show the same (2026-08-16 twice, 2026-09-21 twice). The host also printed Joinable Steam Lobby created with the joiner up. None of the 47 console tails in the evidence folders contains Error Initialising Steam; that is supporting evidence only, because each tail is the last 120 console lines and boot-time lines can fall outside it.

They joined as two players. GET /status gave localClientId 900000000001 on hostie and 900000000002 on joiner (the field is NetworkManager.LocalClientId, TestRig/dev-plugins/TestRig/TestRig/Observe/StateReporter.cs:83), each with instance.valueSources.identity reading manifest. The host hosted over RakNet and the joiner used Direct Connect. From the host's console:

RakNet successfully hosted with Address: 127.0.0.1:27801
Joinable Steam Lobby created
VerifyPlayer - Serialising connection method: RocketNet
Process verify player 900000000002
Verifying RocketNet connection for player 900000000002
Client 900000000002 took control of joiner
Client joiner (900000000002) is ready

and from the joiner's:

Our connection request has been accepted.
VerifyPlayer - Deserialising connection method: RocketNet
id received by server 900000000002
Client sending player response with connection method: RocketNet
Taking control of brain with id 900000000002, userName: joiner

The id received by server, Process verify player and Verifying ... connection for player lines are the format strings quoted in the sections above, so the id on the wire was the manifest's, not the account's.

Measured: both processes held the same SteamID. No captured log line prints it, so it was read from memory, on 2026-09-21 at 06:41 UTC, in a later launch of the same two instances: hostie a listen host in a world, joiner at the main menu and never connected. GET /reflect?type=Steamworks.SteamClient&member=IsValid returned True on both, and GET /reflect?type=Steamworks.SteamClient&member=SteamId returned one 17-digit value on each. A script compared the two without printing either and found them identical; the value is deliberately not recorded here. This confirms what the first pass could only infer from SteamClient.SteamId reading the logged-in user of the one Steam client, which had one account. The steam console command that was first proposed for this check is a stub in this build; see the next section.

What the DLC checks do and do not show. The owner and the non-owner in those checks differ only because the rig clears a bit in one process's memory (POST /dlc/remove, removal only) before that process enters a world. Both processes started with the same answer from Steam, which is what one account predicts. The game never asks Steam again after boot, and the server takes each client's AvailableDLCMessage without validation (DLCGating), so a process-local value is all a session ever sees. That is why one login can stage an owner and a non-owner. It is not evidence of two Steam identities.

Reading a running process's SteamID

The game's steam console command cannot do it in this build. SteamCommand is still registered (["steam"] = new SteamCommand(), 97515) and its help text still promises a status dump, but Execute lowercases its arguments and returns an empty string without calling anything (102804-102817):

public override string HelpText => "Tests the Steamworks integration. With no arguments, prints initialisation status, DLC, and achievement state. Subcommands manage achievements/stats. Subcommands taking arguments are editor-only.";

public override string[] Arguments { get; } = Enum.GetNames(typeof(SteamCommandType));

public override bool IsLaunchCmd => false;

public override string Execute(string[] args)
{
    for (int i = 0; i < args.Length; i++)
    {
        args[i] = args[i].ToLower();
    }
    return "";
}

CheckSteamInitialization (102943-102961), which would print IsInitialised and SteamId, has no call site anywhere in the assembly, and neither do the two subcommand dispatchers, ExecuteSingleArgumentCommand (102819) and ExecuteMultiArgumentCommand (102838). The unreachable status method, verbatim:

private string CheckSteamInitialization()
{
    if (!SteamClient.IsValid)
    {
        return "IsInitialised: false";
    }
    StringBuilder stringBuilder = new StringBuilder();
    stringBuilder.AppendLine("IsInitialised: true");
    stringBuilder.AppendLine($"SteamId: {SteamClient.SteamId}");
    stringBuilder.AppendLine("DLC:\n");
    stringBuilder.AppendLine($"\tDLC ZrilianSpeciesPack: {SteamApps.IsDlcInstalled(1038400u)}");
    stringBuilder.AppendLine($"\tDLC RobotSpeciesPack: {SteamApps.IsDlcInstalled(1038500u)}");
    stringBuilder.AppendLine("Achievements:\n");
    foreach (Achievements.Kind value in Enum.GetValues(typeof(Achievements.Kind)))
    {
        stringBuilder.AppendLine($"\tAchievement {value}: {Achievements.Check(value)}");
    }
    return stringBuilder.ToString();
}

At runtime on 2026-09-21, POST /console/exec {"command":"steam","waitFrames":10} on the two instances of the section above returned no SteamId line and no IsInitialised line on either, and a repeat on hostie returned exactly one console line, the command's own echo steam.

What works is reading the Facepunch statics directly through the TestRig control plane: GET /reflect?type=Steamworks.SteamClient&member=IsValid and GET /reflect?type=Steamworks.SteamClient&member=SteamId on each instance (SteamId is SteamUser.Internal.GetSteamID(), quoted in "Steam initialisation in a client process"). Compare the two values inside a script and print only the verdict, so the account's SteamID64 never reaches a log, a file or a page.

How the test rig gives each instance its own identity

The rig is repository tooling under TestRig/, not game code; this is the mechanism the sections above depend on.

  1. testrig create records a ClientId per instance (--client-id, a decimal ulong, non-zero) and refuses one already used by another instance (TestRig/src/TestRig.Core/Client/ClientHalf.Create.cs:380-397). The launcher writes it into TestRig/ClientRig/data/<instance>/instance.json as a string, because the plugin's JSON number parser goes through double and would lose precision above 2^53, along with username.
  2. It starts the instance's own rocketstation.exe with CreateProcessW: command line -logFile <unique log> -settingspath <instance setting.xml> -screen-width <w> -screen-height <h> -screen-fullscreen 0, working directory the instance's data folder, and a null environment block so the child inherits STATIONEERS_CLIENTRIG_MANIFEST, set around the call to the manifest's path (ClientHalf.Lifecycle.cs, InstanceLauncher.cs, DesktopProcessLauncher.cs). Nothing Steam-specific is passed or set. Steam's log above shows exactly that command line.
  3. The in-game plugin (net.sixfive7.testrig) reads the manifest at Awake: the environment variable first, then instance.json in the working directory, then beside the executable. Manifest values win over its Client - Identity config entries, and /status reports which one won as instance.valueSources.identity.
  4. A Harmony postfix on NetworkManager.Init(TransportType) (Instance/Identity.cs) assigns the override to NetworkManager.Cookie.ClientId and .Username, right after PlayerCookie.Load() has read the shared file and before anything reads LocalClientId. In batch mode, where Cookie is null, it builds one. POST /identity rewrites it live.
  5. A Harmony prefix on PlayerCookie.Save() skips the original whenever an override, or the Lock Cookie File setting, is active, so the per-instance id never reaches the shared file. The development machine's PlayerCookie-v2.xml was last written 2026-07-25 and has not changed through any rig run since, the first of them on 2026-07-30 (checked 2026-09-21). That is consistent with the suppression holding, not proof of it, since a run that never hit a Save() trigger would leave the file alone too.
  6. /connect and /host ask every sibling's control plane for its id (PeerProbe) and refuse a clash unless allowDuplicateIdentity=true.
  7. Joins go over RakNet: /connect calls NetworkClient.JoinClientFromMenu("<address>:<port>"). /host writes UseSteamP2P = false on the host, which disarms ProcessP2PSessionRequest there. A joiner keeps the default true; that does not change its Direct Connect (see above), but it leaves ProcessP2PSessionRequest armed on the joiner.

When one Steam login is not enough

Works on one login, and exercised: several client processes, each with its own ClientId, joining a listen host or the dedicated server by address and port over RakNet, on loopback; staging a DLC owner and a non-owner by removal. RAM, not Steam, is the practical limit on the number of processes (TestRig/RESEARCH.md, "Measured cost").

Needs something one login cannot give:

  1. Steam P2P or a Steam lobby between the instances. A lobby join, a friend's "Join game" into a lobby, a 17-character SteamID typed into Direct Connect, and a server-browser entry with a valid HostSteamId while UseSteamP2P is on all address the peer by SteamID alone, and every process on the login has the same one. Nothing can name one instance rather than another, and a host keys P2P connections on the SteamID and drops an existing client when the same SteamID asks again. This rests on the code above; no same-account P2P join has been attempted at runtime.
  2. Several instances joining someone else's host over P2P. The same ProcessP2PSessionRequest logic seen from the host: the second instance's request evicts the first.
  3. A server that authenticates with Steam. Vanilla does not (Authenticate has no caller), but a mod or a future build that checks a Steam ticket against the presented id would see one account for every instance, and a rig ClientId that is not a SteamID at all. Re-check after every game update: search the decompile for Authenticate( and BeginAuthSession, and read VerifyConnection for anything beyond the blacklist, the password and the version.
  4. A server or mod that keys on the real SteamID (a whitelist, an admin list, per-player permissions or data). A rig instance with a synthetic id is a stranger to it, and only one process can present the real id without the duplicate-id collision above.
  5. Entitlement that must differ as Steam sees it. One login gives every process the account's DLC. The rig can take entitlement away in memory and cannot add it. A test that needs a process to own a DLC the account does not, or a test of anything that asks Steam after boot, needs a second account with different ownership.
  6. Per-player Steam state. Workshop subscriptions and publishing, achievements and stats, rich presence and the "now playing" record, friends and avatars all belong to the one account, and the Steam client even merges the processes into one game record.
  7. Launching through Steam. The arrangement depends on starting the executable directly. How the Steam client treats a library launch of AppID 544550 while other processes hold its game record has not been observed.

A second Steam login would not have fixed what it is usually blamed for. The cookie is per Windows user and is used whenever it exists, so a second Steam account logged in under the same Windows user would still read the first account's ClientId. What separates players is a distinct ClientId per process, which vanilla also allows without Steam: a cookie with a hand-set ClientId, or a new cookie created while Steam is unavailable, which takes GenerateUniqueClientId().

Verification history

  • 2026-09-21: page created at 0.2.6428.27798 from the Assembly-CSharp decompile, a fresh decompile of Facepunch.Steamworks.Win64.dll (sha256 above), the StationeersLaunchPad decompile, the TestRig plugin and launcher source, the Steam client's logs, registry key and loginusers.vdf, and the rig's playtest evidence of 2026-08-16 and 2026-09-21. It records the mechanism behind the correction of option D ("Requires two Steam logins") on StationeersLaunchPadDedicatedServer, where the fresh validator returned B with two qualifications, both carried here: a per-process ClientId is required and not merely sufficient, and the P2P and lobby conditions rest on code rather than on a runtime test. The same code findings were first made at 0.2.6403.27689 on 2026-07-29 in session scratch notes that were never curated; every line number on this page was re-read at 0.2.6428.27798.
  • 2026-09-21 (second pass): measured what the first pass could only infer. On two TestRig instances on one Steam login, one a listen host in a world and one at the main menu, GET /reflect on Steamworks.SteamClient returned IsValid True on both and one 17-digit SteamId on each, identical when compared inside a script; no value was printed or recorded. The Summary gained the measurement, and "Several client processes on one Steam login" replaced its "Not measured" paragraph with it. The Open-questions recipe for this check could not have worked: the steam console command is a stub in this build (SteamCommand.Execute returns an empty string, and CheckSteamInitialization has no caller; at runtime the command printed nothing but its own echo). Added "Reading a running process's SteamID" with the stub, verbatim, and the reflection method, and removed the resolved Open question. No verified claim about the game changed; the measurement agrees with the page.

Open questions

  • How the Steam client routes a P2P session request addressed to an account that has several game processes attached, and so whether the ProcessP2PSessionRequest promotion described on ListenHost can reach a rig joiner, which keeps UseSteamP2P = true. Not observable in managed code, and never observed in a rig run.
  • How the Steam client treats a Steam library launch of AppID 544550 while rig processes hold the game record: refusal, a second launch, or a takeover of the record. Never attempted.
  • Whether the merged game record changes anything a process reads back from Steam, such as rich presence or the overlay. Not observed.