Driving the game client programmatically
Everything a BepInEx plugin needs in order to drive the Stationeers client with nobody at the
keyboard: the seams for input, joining a server, spawning, screenshots and live mod config, plus
the boot-order and lifecycle traps that make a naive implementation fail silently. All of it was
exercised against a live client on 0.2.6403.27689; the implementation is the ClientDriver plugin
and its launcher under TestRig/ClientRig/.
Plugin lifecycle: the BepInEx component is destroyed during boot¶
OnDestroy fires on a BaseUnityPlugin component roughly a minute into startup while the process
keeps running. After that point BepInEx.Bootstrap.Chainloader.PluginInfos[guid].Instance is null
for every plugin, including StationeersLaunchPad itself.
Consequences:
- Anything torn down from
OnDestroydies a minute after launch. ATcpListenerstopped there leaves the process alive with nothing listening and no error anywhere. Application.quittingis the only teardown signal that actually means the process is going away.- Long-lived state belongs to a static, not to the component.
A DontDestroyOnLoad GameObject created by the plugin is also destroyed and must be recreated;
observed exactly twice per session in ClientDriver's counter.
Per-frame main-thread hooks that survive everything¶
Assets.Scripts.UI.ImGuiManager.LateUpdate() (public, Assembly-CSharp line 238964) runs every
frame from the splash screen onwards and belongs to the game, so a Harmony postfix on it is a
main-thread pump that is immune to the lifecycle problem above. Its body:
public void LateUpdate()
{
if (renderTexture.width != Screen.width || renderTexture.height != Screen.height)
{
CreateRenderTexture();
}
RenderOverlay();
}
RenderOverlay() (line 238972) is where the in-game ImGui frame lives:
private void RenderOverlay()
{
PrepareImGuiFrame();
ConsoleWindow.Draw();
if (SplashBehaviour.IsActive) { SplashBehaviour.Draw(); }
else if (ImGuiLoadingScreen.IsShowing) { ImGuiLoadingScreen.DrawStandardLoading(); }
else
{
OrbitalSimulation.Draw();
CinematicCamera.DrawOverlay();
...
}
}
Two things follow. First, ConsoleWindow.Draw() is outside the branch, so the console renders at
every phase. Second, OrbitalSimulation.Draw() is skipped entirely while the splash screen or the
loading screen is up, and StationeersLaunchPad hangs all of its in-game ImGui windows off a prefix
on that method. Any plugin drawing ImGui through the same seam is invisible until the real main
menu is reached.
Fallback pumps, in the order ClientDriver uses them: ImGuiManager.LateUpdate postfix (primary),
MonoBehaviour.Update on an own GameObject (secondary), ElectricityManager.ElectricityTick
postfix (tertiary, the one InspectorPlus and ScenarioRunner rely on headless).
Console capture¶
Every Assets.Scripts.ConsoleWindow print overload funnels into one method:
public static void Print(string output, ConsoleColor color = ConsoleColor.White,
bool clearLine = false, bool aged = true, bool unformatted = false)
PrintError(string, bool) calls it with ConsoleColor.Red then prints Environment.StackTrace in
Gray. PrintAction(string, bool) calls it with ConsoleColor.Yellow. All seven GameString
overloads format and delegate. A single Harmony postfix on the five-argument Print therefore
captures the lot.
Not captured by that postfix: PrintBlock, PrintSegmentedBlock, PrintSegmentedBlockRaw and
PrintTable, which write straight into the ring. For those, and for anything printed before the
plugin loaded, read the game's own ring directly: ConsoleWindow.ConsoleBuffer is a public
ConsoleLine[] of DEFAULT_CONSOLE_BUFFER_SIZE = 1024 entries, index 0 newest.
ConsoleLine exposes Color (uint), Time, Text and Continuations (the tail of a multi-line
message, split on \n).
ConsoleWindow.Submit(string) is public static and is the console's own input path: it prints the
echo then calls CommandLine.Process. Capturing from a sequence number taken before the call gets
the echo plus all output. Util.Commands.CommandLine.AddCommand(string, CommandBase) is public,
so a plugin can register its own console commands.
Note the namespace trap: ConsoleWindow is in the bare Assets.Scripts namespace and clashes with
Assets.Scripts.Settings. Use using ConsoleWindow = Assets.Scripts.ConsoleWindow;.
BepInEx-side capture is a separate stream. Registering an ILogListener on
BepInEx.Logging.Logger.Listeners catches every plugin's log and, because UnityLogListening is on
in BepInEx.cfg, every Debug.Log in the process as well. During mod load that is thousands of
lines in a couple of seconds, so it needs its own ring buffer or it evicts the console lines a test
actually cares about.
Input injection¶
Patch UnityEngine.Input, not KeyManager. Every KeyManager query bottoms out in the Unity layer:
public static bool GetButton(KeyCode key)
{
if (key != KeyMap.ToggleConsole && ConsoleWindow.IsOpen) { return false; }
return Input.GetKey(key);
}
public static bool GetMouse(string key)
{
if (key.Equals("Primary")) { return GetButton(KeyMap.PrimaryAction); }
return GetButton(KeyMap.SecondaryAction);
}
and a large amount of game code calls Input.GetKey(KeyMap.X) directly, bypassing KeyManager.
Patching Input.GetKey / GetKeyDown / GetKeyUp / GetMouseButton* / get_mouseScrollDelta /
get_mousePosition covers both. All of those are managed wrappers over externs on Unity
2022.3.62, so Harmony patches them without complaint (confirmed applied at runtime).
KeyMap is a static class of mutable public static KeyCode fields, not an enum, and the values
are rebindable at runtime from settings. Resolve an action name against the live field rather than
hardcoding a default. KeyMap.PrimaryAction defaults to KeyCode.Mouse0, SecondaryAction to
Mouse1, ToggleConsole to F3, SwapHands to E. KeyMap.Teleport is vestigial: it is bound
to ToggleNightVision and saved under the settings key "NightVision".
Express synthetic input as an absolute Time.frameCount window, never as a countdown ticked from
Update. MonoBehaviour update order is undefined, so a countdown can expire before the frame's
real consumer runs. Open the window on Time.frameCount + 1 and it is visible for the whole of
every frame in it regardless of ordering.
Mouse wheel specifics. InventoryManager.CheckDisplaySlotInput() caches
newScrollData = Input.mouseScrollDelta.y / 10f at the top of every frame, and that public field is
what most consumers read, including SprayPaintPlus's ColorCyclerPatch. PrecisionPlacementMode
zoom and a few UI paths read Input.mouseScrollDelta directly instead. Patching the Unity property
covers everything; a postfix on CheckDisplaySlotInput that assigns newScrollData is a useful
backstop.
One frame of wheel is one notch. Consumers act once per frame, so a two-frame injection scrolls
a spray can two colours. Verified in world: frames=1 steps the colour index by exactly 1 per call.
Re-confirmed on 0.2.6403.27689: there is no cached key state anywhere for the patch to sit under.
InputSystem.KeyWrap.PollForInput (line 191636) calls Input.GetKeyDown / GetKey / GetKeyUp
directly and fires its C# events synchronously from inside that call stack; its IsPressed and
IsPressedThisFrame properties are written there and read by nothing in the assembly.
KeyMap.PollInputs (44823) only fans out over a HashSet<KeyWrap>. KeyManager.GetButton,
GetButtonDown and GetButtonUp (44446-44471) are a ConsoleWindow.IsOpen short-circuit plus the
live Unity call. There are 139 direct Input.* call sites in Assembly-CSharp, so the Unity layer
really is the one chokepoint. The modern input package is absent: no UnityEngine.InputSystem
references, and no Unity.InputSystem.dll in rocketstation_Data\Managed\, only
UnityEngine.InputLegacyModule.dll.
The gameplay input gate: Cursor.visible kills per-frame input¶
Patching UnityEngine.Input delivers the value. It does not guarantee anything consumes it. The
consumer side has a gate that closes on its own in a background window, and when it is shut the
symptom is indistinguishable from injection not working: synthetic keys and wheel do nothing, while
direct method calls on the same objects work perfectly.
Assets.Scripts.Inventory.InventoryManager.ManagerUpdate (287152), abbreviated to the branch that
matters:
if (Cursor.visible || Parent.IsUnresponsive || ConsoleWindow.IsOpen)
{
return;
}
CheckDisplaySlotInput();
CheckSeatedInput();
...
if (Parent.State == EntityState.Alive && IsAllowedToLook() && IsParentSafe() && !Stationpedia.IsOpenAndLocked)
{
switch (CurrentMode)
{
case Mode.Normal:
NormalMode();
break;
case Mode.Placement:
PlacementMode();
break;
case Mode.PrecisionPlacement:
PrecisionPlacementMode();
break;
Everything below that early return stops:
CheckDisplaySlotInput(287367) is the only writer ofInventoryManager.newScrollDatain the whole assembly. A regex search fornewScrollData\s*=returns exactly one hit, line 287369,newScrollData = Input.mouseScrollDelta.y / 10f. There is no reset to zero anywhere; the once-per-frame overwrite is the reset. So when the gate is shut the wheel is never sampled and the field keeps its last value indefinitely.NormalMode(288000) never runs, which takes with it every mod that hangs a per-frame patch there. SprayPaintPlus'sColorCyclerPatchis a prefix onNormalMode, and that is where it packs and sends its client-half preference mask, so a driven client silently never reports its own settings to the server.
Note that NormalMode does not read newScrollData itself; the placement-rotation reads at 288400,
288404, 288470, 288474, 288618 and 288622 are all inside PlacementMode (288320).
PrecisionPlacementMode (288256) bypasses the field and reads Input.mouseScrollDelta.y raw.
The same term gates movement, at 211510:
if (KeyManager.InputState != KeyInputState.Game || Cursor.visible || !IsGround || _jumping || !IsInputAscend)
Why an unfocused window ends up here. Unity releases the cursor lock when the application loses
focus. MouseModeController.Check (201335) tries to re-lock every frame and SetState (201363)
re-issues CursorManager.SetCursor(locked) whenever the actual state does not match, but it cannot
take the lock back while the window is in the background, so Cursor.visible stays true for as long
as the window is not foreground.
Nothing in the managed code checks application focus directly. Application.isFocused has zero
occurrences in Assembly-CSharp. Application.runInBackground appears once, at 102476, inside a
diagnostic print. The single OnApplicationFocus (201248, on CursorManager) only restores cursor
state when focus is regained. The focus dependency is entirely this second-order one through the
cursor.
Working around it in-process. Assert the cursor state from a Harmony prefix on
InventoryManager.ManagerUpdate itself, so the write lands a few instructions before the gate reads
it and nothing can intervene:
if (Cursor.visible) Cursor.visible = false;
if (Cursor.lockState != CursorLockMode.Locked) Cursor.lockState = CursorLockMode.Locked;
This needs no window focus and no OS input, so it keeps the never-focus property that makes the
in-process design worth having. It is only correct for a client nobody is sitting at: it takes the
mouse cursor away from a real player. ClientDriver gates it behind
Client - Gameplay Input / Force Gameplay Input, default off.
Two further gates worth knowing, both of which report as "input did nothing":
ConsoleWindow.IsOpenshort-circuits everyKeyManager.GetButton*call (44448, 44457, 44466) for any key other thanKeyMap.ToggleConsole, as well as appearing in theManagerUpdategate.KeyWrapBindings.KeyWrapOnEvent(191728) filters every KeyWrap-bound action onitem.inputState == KeyInputState.All || item.inputState.HasFlag(KeyManager.InputState).KeyManager.InputState(43706) is a public getter with a private setter, defaults toKeyInputState.Game, and is moved only bySetInputState/RemoveInputState(43864, 43870) through aDictionary<string, KeyInputState>stack. A panel that pushes a state and never pops it leaves every bound action inert.KeyMap._SwapHands.Bind(InputPhase.Down, SwapHandsOnKeyUp, KeyInputState.Game)at 43771 is one of those, which is why a synthetic SwapHands can be delivered and still do nothing.
enum KeyInputState : byte { All = 0, Game = 2, Paused = 4, Typing = 8, Cinematic = 0x10 } (43638).
Diagnosing it. Do not infer this from behaviour. Prefix and postfix each link of the chain and
compare enter counts across the input window: GameManager.Update (which runs the manager loop at
GameManager.decompiled.cs:1540-1543), KeyManager.ManagerUpdate (43736), KeyMap.PollInputs,
InventoryManager.ManagerUpdate, InventoryManager.CheckDisplaySlotInput,
InventoryManager.NormalMode. A link whose enter count stops advancing is not being reached; a link
whose enter count outruns its exit count is throwing. ClientDriver exposes this as GET /diag/input.
Window size and fullscreen come from the game, not the command line¶
-screen-width, -screen-height and -screen-fullscreen are consumed by the Unity player when it
creates the window, and the game then overrides them twice with its own saved preference. Both call
sites are unguarded, so this happens in batch mode too.
// Settings.LoadSettings(), line 266641, reached from WorldManager.ManagerAwake() line 59685,
// which sits ABOVE that method's `if (!GameManager.IsBatchMode)` block
if (int.TryParse(CurrentData.ScreenWidth, out var result) && int.TryParse(CurrentData.ScreenHeight, out var result2))
{
Screen.SetResolution(result, result2, CurrentData.FullScreen, CurrentData.RefreshRate);
}
// Settings.ApplyVideoSettings(), line 266686, the last statement of GameManager.Start() at 205102,
// which runs AFTER CommandLine.ExecutePostLaunchCommands()
Screen.SetResolution(int.Parse(CurrentData.ScreenWidth), int.Parse(CurrentData.ScreenHeight),
CurrentData.FullScreen, CurrentData.RefreshRate);
The fields are [XmlElement] members of SettingData (265393-265424), so they are exactly the
element names in setting.xml:
[XmlElement] public int Monitor = 1;
[XmlElement] public string ScreenWidth = "1920";
[XmlElement] public string ScreenHeight = "1080";
[XmlElement] public int RefreshRate = 60;
[XmlElement] public bool FullScreen = true;
[XmlElement] public bool Vsync;
Consequences for a driven instance:
<FullScreen>defaults to true, so an instance whosesetting.xmlwas never edited comes up fullscreen no matter what the launch line said.-settingspathgives each instance its own file, so setting<FullScreen>false</FullScreen>there is per-instance and costs nothing.ScreenWidthandScreenHeightare strings.LoadSettingsusesint.TryParseand tolerates garbage;ApplyVideoSettingsuses a bareint.Parseand will throw insideGameManager.Start(), anasync void, if the value is not numeric.Screen.fullScreenModeand theFullScreenModeenum have zero occurrences; the game only ever uses the legacybool fullscreenoverload.<Monitor>is serialized and read by nothing.CurrentData.Monitorhas no consumers.- The game never writes Unity's own PlayerPrefs screen keys: the string
Screenmanagerhas zero occurrences in the assembly, and the ninePlayerPrefscall sites are all server-browser filter and sort state (FILTER_VERSION,FILTER_PASSWORD,FILTER_EMPTY,JoinSortBy) plusChatSize. The values underHKCU\Software\Rocketwerkz\rocketstationare Unity's, written natively.
The in-process fix is to correct Settings.CurrentData before the game reads it, rather than to call
Screen.SetResolution afterwards and be overwritten. A prefix on ApplyVideoSettings that rewrites
the three fields makes the game's own call ask for a window.
Resolving the Settings type by name is a trap: it is in the global namespace, and other loaded
assemblies carry their own Settings, so AccessTools.TypeByName("Settings") can return the wrong
one. Scan for a type named Settings that has both a static CurrentData field and a static
LoadSettings method.
Direct Connect¶
One public instance method does the whole join:
It runs ClientPreJoin() (sets GameManager.GameState = GameState.Joining), splits the address,
and calls NetworkManager.StartClient(address, port, localPort), then OnJoinStart(). Calling
StartClient directly skips the menu teardown and the loading screen, so use the menu method.
Getting the instance is the first trap: FindObjectOfType<NetworkClient>() only sees active,
enabled components and returns null for roughly the first minute of a session. Fall back to
Resources.FindObjectsOfTypeAll<NetworkClient>(), which includes inactive objects, and poll until
it appears.
The second trap is the join timer. OnJoinStart arms a 10 second AutoReset timer whose elapsed
handler does nothing but give up:
private static async void ConnectionTimerOnElapsed(object sender, ElapsedEventArgs e)
{
ConsoleWindow.PrintError("Connection could not be established");
await UniTask.SwitchToMainThread();
StopConnectionTimer();
Singleton<ConfirmationPanel>.Instance.Show(MultiplayerCouldNotConnectKey, MultiplayerCheckAddressKey, "ButtonOk", Cancel);
}
Ten seconds is not enough for a heavily modded dedicated server. Observed: the server logs
A connection is incoming and VerifyPlayer - Serialising connection method: RocketNet, then the
client cancels itself twenty seconds later. NetworkClient.StopConnectionTimer() is public static;
call it immediately after JoinClientFromMenu and impose your own timeout instead.
Two dead ends worth knowing. -join <address:port> does exist as a launch command
(JoinCommand, keys join and joingame), but unlike LoadGameCommand and NewGameCommand it
does not override RequiresGameManagerIsInitialized, so it runs at
RuntimeInitializeOnLoadMethod time and prints "network client not initialised yet" if the
component is not up. And a password-protected server prompts through
PasswordWindow.PromptPassword, with no client-side field to pre-fill.
Leaving: GameManager.LeaveGame() (public static) tears the session down;
NetworkClient.Cancel() aborts a pending join and calls LeaveGame itself. Both return before
the menu is actually back, so poll GameManager.GameState == GameState.None.
State accessors¶
| What | Accessor | Notes |
|---|---|---|
| Session phase | GameManager.GameState |
enum GameState : byte { None, Joining, Waiting, Running, Loading, Paused }. Public static get AND set. |
| Client vs host | NetworkManager.NetworkRole, .NetworkState, .IsClient, .IsServer |
public static fields/properties |
| Simulation owner | GameManager.RunSimulation |
=> !NetworkManager.IsClient |
| World | WorldManager.CurrentWorldName, .CurrentWorldId, .IsGamePaused |
WorldManager is in the global namespace |
| Local player | Human.LocalHuman |
one-line forwarder to InventoryManager.ParentHuman; null at the menu |
| Position | Thing.ThingTransformPosition |
Thing.Position is a cached copy, up to one FixedUpdate stale |
| Look | CameraController.Instance.RotationX (pitch, positive is up) and .RotationY (yaw) |
|
| Hands | InventoryManager.ActiveHandSlot, InventoryManager.Instance.ActiveHand/.InactiveHand |
Slot.Get() returns the occupant; Slot.Occupant is [Obsolete] |
| Cursor target | CursorManager.CursorThing |
rebuilt every frame by CursorManager.SetCursorTarget() |
WorldManager.IsLoaded, NetworkManager.Instance.LocalClient and GameManager.Instance.Player do
not exist. There is no CharacterController anywhere in the assembly; movement is pure Rigidbody.
Teleport, look, and using an item¶
Human.ForceSetPosition(Vector3) is gated on GameManager.RunSimulation, which is false on a
multiplayer client, so it is a silent no-op there. The game's own teleport console command
sidesteps it and writes InventoryManager.Parent.Transform.position raw. The reliable sequence,
which is ForceSetPosition minus the gate:
human.ThingTransformPosition = target; // writes the transform and the Position cache
human.Transform.position = target;
rb.MovePosition(target);
if (!rb.isKinematic) { rb.velocity = Vector3.zero; rb.angularVelocity = Vector3.zero; }
human.ResetInterpolation(); // public, on DynamicThing
Verified in single player: position moved exactly as requested and stuck.
Look direction: CameraController.UnitTest_SetRotation(float InRotationX, float InRotationY) is
public and sets pitch then yaw. SetMouseLook() adds mouse delta to both every LateUpdate, so a
one-shot write holds only while the mouse is still, which is the normal case for a driven client.
To aim at a world point:
Vector3 dir = (worldPoint - CameraController.CameraOrigin).normalized;
float yaw = Mathf.Atan2(dir.x, dir.z) * Mathf.Rad2Deg;
float pitch = Mathf.Asin(dir.y) * Mathf.Rad2Deg; // positive is up, matching RotationX
CameraController.Instance.UnitTest_SetRotation(pitch, yaw);
Verified: a target 10 m along +Z gives yaw 0 pitch 0, 10 m along +X gives yaw 90, and 10 m forward and 10 m down gives pitch -45.
Using the held item on a specific target, without aiming, goes through:
// global namespace
public static void OnServer.AttackWith(Thing attackParent, byte activeHandSlotId, byte offHandSlotId,
long targetId, Vector3 attackPosition, float completedRatio,
bool isDestroy, bool isCopy)
It predicts locally with doAction: GameManager.RunSimulation and sends an AttackWithMessage when
NetworkManager.IsClient, so it is correct on both a host and a client. It constructs the Attack
with targetCollider: null, so an override that dereferences attack.TargetCollider would throw;
the spray-paint path does not. Verified end to end in single player: a spray can in the active hand
plus AttackWith(targetId: <wall>) changed the wall's CustomColor.Index to the can's colour.
Spawning¶
Prefab lookup is by hash, with string overloads that wrap Animator.StringToHash:
Prefab.Find(string), Prefab.Find<T>(string), Prefab.TryFind(...), and the full list in
Prefab.AllPrefabs. Prefab.Find returns null on a miss; TryFind<T> throws internally and
Find<T> swallows it and returns default, so prefer Find(name) plus a null check.
| Goal | Call | Authority |
|---|---|---|
| Item straight into a slot | OnServer.Create<T>(Thing prefab, Slot slot) |
needs RunSimulation, so host or single player |
| Item on the ground near a player | OnServer.SpawnDynamicThingMaxStack(long parentId, string prefabName) |
client-safe, forwards to the server |
| Item at an arbitrary position | OnServer.Create<T>(Thing prefab, Vector3 pos, Quaternion rot) |
server side |
| Built structure on the grid | Constructor.SpawnConstruct(CreateStructureInstance) |
client-safe, sends ConstructionCreationMessage |
CreateStructureInstance(Structure prefab, Grid3 localGrid, Quaternion worldRotation, ulong ownerClientId, int colorIndex = -1);
get the grid coordinate with GridController.World.WorldToLocal(worldPosition). Verified: a
StructureCompositeWall placed 3 m in front of the player landed on the grid, fully built and
paintable, and reported a sensible CustomColor.Index.
Spray can prefabs are ItemSprayCan<Color> for the 12 base colours and
ItemSprayCanMetallic<Colour> for the four DLC ones; 16 in total, all present in Prefab.AllPrefabs.
The colour lives on the can as a Material (SprayCan.PaintMaterial), not an index;
GameManager.GetColorIndex(Material) maps it back to a swatch index.
Screenshots¶
Only ScreenCapture.CaptureScreenshotAsTexture() after yield return new WaitForEndOfFrame()
includes overlay UI. The game's own GameManager.CreateScreenShot(int, int, Camera) and
GameManager.SaveScreenShot(...) render a Camera into a RenderTexture, which is right for a world
thumbnail but silently omits every uGUI canvas and every ImGui panel. Anything that needs to prove
a panel rendered readable text must go through the backbuffer.
Cost at 3840x2160: about 6 MB of PNG per shot, roughly 500 ms end to end. A Graphics.Blit through
a smaller RenderTexture before EncodeToPNG cuts that by an order of magnitude and stays sharp
enough to read UI text.
Mod config at runtime¶
Chainloader.PluginInfos only ever lists what BepInEx loaded out of BepInEx/plugins/. On a
normal client that is StationeersLaunchPad and nothing else; every Workshop mod arrives through
StationeersLaunchPad and is invisible to it. Combined with the destroyed-component problem above,
Chainloader.PluginInfos[guid].Instance.Config is useless for reaching a mod's settings.
What works: scan AppDomain.CurrentDomain.GetAssemblies() for a type carrying
[BepInPlugin(guid, ...)], then walk its static members for any ConfigEntryBase and read
entry.ConfigFile. A ConfigEntry holds a reference to its owning ConfigFile, and both outlive
the MonoBehaviour. From the ConfigFile the whole Keys collection is reachable, and
ConfigEntryBase.BoxedValue is settable with a value produced by
TomlTypeConverter.ConvertToValue(string, entry.SettingType).
Verified end to end: writing net.spraypaintplus / Client - Color Cycling / Color Cycling from
AllColors to WithinFamily changed the value SprayPaintPlus.SettingsMerge.EffectiveColorCycling
computes, immediately, with no restart.
Scanning by assembly also exposes duplicates: two loaded copies of the same mod assembly both carry
the attribute, and only the copy whose Awake ran has populated statics. Try every match rather
than the first.
The StationeersLaunchPad settings panel¶
StationeersLaunchPad.ConfigPanel.DrawWorkshopConfig(ModInfo) is public static and opens its own
ImGui window. Vanilla only calls it while the Workshop menu is open and a mod row is selected:
private static void DrawWorkshopMenuConfig()
{
if (((Behaviour)WorkshopMenu.Instance).isActiveAndEnabled)
{
... workshopMenuSelectedField.GetValue(WorkshopMenu.Instance) ...
ConfigPanel.DrawWorkshopConfig(LaunchPadConfig.MatchMod(...));
}
}
Calling DrawWorkshopConfig directly from a prefix on OrbitalSimulation.Draw renders the same
panel with no menu state involved, which makes the panel screenshot-testable without driving a
mouse through a list. The ModInfo to pass is LoadedMod.Info from ModLoader.LoadedMods.
Because the seam is OrbitalSimulation.Draw, this only works once the real main menu is up; see
the RenderOverlay branch above.
Failure modes seen on a live client¶
Transient Steam Workshop failure parks the client forever. When
StationeersLaunchPad.Steam.FetchWorkshopPage throws (a NullReferenceException out of
Steamworks.Helpers.TakeMemory inside ISteamUGC.GetQueryUGCPreviewURL), StationeersLaunchPad
prints "Error occurred during initialisation. Mods will not be loaded.", shows its own ImGui screen
with a Start Game button, and never reaches the menu. Loaded plugin count stays at 2. It clears on
relaunch; an unattended loop needs to detect it and retry.
ConfirmationPanel.IsVisible is a false positive during boot. It is
gameObject.activeInHierarchy, which is true for a window early in startup with an empty
_dataStack behind it. Treat a dialog as showing only when the stack has data.
StationeersLua and ScriptedScreens threw an exception every single frame, on 0.9.5.0 only.
Fixed upstream 2026-08-13: both shipped 1.0.0.0 and the exception no longer reproduces (a fresh
client boot with both enabled logs zero DynamicAssemblyFactory, zero TypeInitializationException
and zero System.Collections.Immutable load failures). The rest of this entry is the 0.9.5.0
record, kept because the console-eviction consequence is what a rig operator needs to recognise if
any mod does this again. The
ScriptedScreensScriptableUiSystem static ctor reaches MessagePack directly, in one hop, with no
intermediary type: its last statement is
MpOptions = MessagePackSerializerOptions.Standard.WithResolver(ContractlessStandardResolver.Instance).WithCompression(...),
and from there ContractlessStandardResolver -> StandardResolverHelper ->
ImmutableCollectionResolver pulls System.Collections.Immutable, Version=8.0.0.0, which fails with
Could not load file or assembly 'System.Collections.Immutable, Version=8.0.0.0'. Once the ctor
fails, every later access rethrows TypeInitializationException, and it rethrows from
ScriptedScreensBehaviorPatch.KeyWrap_PollForInput_Prefix -> KeyMap.PollInputs ->
KeyManager.ManagerUpdate -> GameManager.Update, i.e. every frame for the rest of the session,
at the main menu and in world alike. It floods the console ring hard enough to evict thousands of
lines. Disabling both mods removes the exception but does not by itself fix a stalled join.
StationeersLua has a second, separate per-frame site that reaches the same poisoned library
(McpServerTickPatch.GameManager_Update_Postfix -> McpMultiplayerDebugProxy.ShouldProxyRequestsLocally());
it is not part of the ScriptedScreens chain. Mechanism, blast radius and the resolution gap:
Patterns/ModDependencyAssemblyResolution.md.
It is observationally log spam, not a functional break. Field evidence from the developer:
they host a session with both mods enabled and a second player joins normally, with the exception
firing throughout. That settles it, and it is worth recording because the mechanism invites a
stronger conclusion than the evidence supports. GameManager.Update does iterate its managers
with no try/catch, and NetworkManager.ManagerUpdate is the client's only network receive pump,
so a throwing manager ordered before it WOULD stop all packet processing. But Managers is a
public List<ManagerBase> populated by Unity serialization, not by code, so the order is not in
the decompile at all and the "throws before the pump" step was never established. Do not assert
it without evidence. Copying System.Collections.Immutable.dll into BepInEx/core/ was tried once
and a stalled join succeeded afterwards, but that is a single correlated observation and the
transient StationeersLaunchPad Workshop hang documented above is an equally good explanation for
the same pair of events.
Attribution was checked properly rather than assumed, because a driving plugin patching
UnityEngine.Input is an obvious suspect for a fault on the input path. Counting occurrences in
BepInEx/LogOutput.log is misleading: BepInEx collapses the repeats there, so the file shows single
digits while the game console is taking thousands. Measured against the game's own console instead,
with the UnityEngine.Input patches never applied at all, the exception still fires at the same
rate. It is not caused by patching Input.
Keeping a driven session out of the developer's save folder¶
StationSaveUtils.GetSavePath() resolves Settings.CurrentData.SavePath on every call and creates
saves, scripts and mods beneath it:
public static string GetSavePath()
{
if (string.IsNullOrEmpty(Settings.CurrentData.SavePath)) { Settings.CurrentData.SavePath = DefaultPath; }
string savePath = Settings.CurrentData.SavePath;
string text = Path.Combine(savePath, "saves");
...
}
Assigning Settings.CurrentData.SavePath at runtime before creating a world therefore redirects
every write to a scratch directory. The change is in memory; the game persists settings on a clean
exit, so restore it or exit hard. Verified: a world created after the redirect wrote only into the
scratch tree, and the real saves folder kept its original timestamp.
The world ids accepted by the new console command are Europa3, Lunar, Mars2,
MimasHerschel, Venus, Vulcan (Deprecated), Vulcan2. Moon is not one of them, despite the
Lunar world's display name being "Moon: Great Mare".
Verification history¶
- 2026-08-13, 0.2.6428.27798: additive, contradicting nothing below. Both mods shipped 1.0.0.0 on
2026-08-13 and the per-frame
System.Collections.Immutableexception no longer reproduces: a fresh client boot with both enabled logs zeroDynamicAssemblyFactory, zeroTypeInitializationExceptionand zero occurrences of the load failure, and the client reaches the menu and loads a save. The 0.9.5.0 record, including the corrected static-ctor chain and the "observationally log spam" framing, is unchanged and kept. The bundledSystem.Collections.Immutable.dllis byte-identical to the 0.9.5.0 copy andMessagePackstill references the same 8.0.0.0 identity; only the MessagePack build moved, 3.1.7.0 to 3.1.8.0. No mechanism is claimed. Detail: Patterns/ModDependencyAssemblyResolution.md. Separately verified on the same date:DynamicThing.SetSlotTypes, whose removal on 0.2.6420.27780 stopped these two mods reaching a usable menu at all, is no longer called by either (GameClasses/DynamicThing.md). - 2026-08-09: corrected the framing of the
System.Collections.Immutableexception. It is observationally log spam: the developer hosts with both mods enabled and a second player joins normally while it fires. The mechanism invites a stronger conclusion, and the missing step is thatGameManager.Managersis Unity-serialized rather than built in code, so the manager order is not in the decompile and "throws before the network pump" was never established. The single correlated observation that a stalled join succeeded after adding the assembly toBepInEx/core/is not evidence of cause; the transient StationeersLaunchPad Workshop hang explains the same pair of events. The assembly has been removed from the developer's install. - 2026-07-27: page created from the ClientDriver build-out. Every section exercised against a live
client on 0.2.6403.27689 with StationeersLaunchPad 0.5.0 and 35 Workshop mods loaded: console
capture, console command execution, synthetic keys (F3 toggling the console, observed through
ConsoleWindow.IsOpen), mouse wheel driving SprayPaintPlus colour cycling, teleport, look, look-at, spawn into hand, structure placement,AttackWithpainting a wall, backbuffer screenshots at the menu and in world, forced LaunchPad settings panel, and a live ConfigEntry write reflected in the mod's computed value. - 2026-07-30: added "The gameplay input gate" and "Window size and fullscreen come from the game, not
the command line". Both are additive; nothing previously on the page was contradicted. The "Input
injection" section was re-read against the current decompile and re-confirmed (there is no cached
key state to sit under;
KeyWrap.PollForInputcallsUnityEngine.Inputlive), so it was restamped rather than changed. The gate section supplies the missing precondition for the already-recorded "frames=1 steps the colour index by exactly 1 per call" result: it holds only whileInventoryManager.ManagerUpdategets past itsCursor.visibleearly return. - 2026-07-27: corrected the StationeersLua / ScriptedScreens entry. It was first written as "fires
once multiplayer is entered", based on occurrence counts in
BepInEx/LogOutput.log. That file collapses the repeats and is not a usable measure. Re-measured against the game's own console ring with the plugin'sUnityEngine.Inputpatches disabled: the exception fires continuously with no join involved and with no input patching in the process. - 2026-08-11: conflict on "how the
ScriptedScreensstatic ctor reaches MessagePack". Previous claim: the ctor pullsMcpMultiplayerDebugProxy, which pulls MessagePack. New finding: the ctor reaches MessagePack directly in one hop, andMcpMultiplayerDebugProxyis aStationeersLuatype thatScriptedScreens.dlldoes not reference at all. Fresh validator verdict: the new finding is correct, established from the assembly TypeDef and TypeRef tables viaPEReader/MetadataReaderrather than a text search, and corroborated against the.cctorIL. The name appears in neither table ofScriptedScreens.dll; it is a TypeDef inStationeersLua.dll(StationeersLua.McpServer.McpMultiplayerDebugProxy).ScriptedScreens.dllreferences only threeStationeersLuatypes (LuaMcpRegistry,McpServer.McpEditorContext,McpServer.McpServerConfig), none of them touched by the ctor. Result: the chain was corrected in place and the separateStationeersLuaper-frame site recorded alongside it. The rest of the entry, including theKeyWrap_PollForInput_Prefixrethrow path, was confirmed unchanged.
Open questions¶
- A Direct Connect to this repo's dedicated server reaches the server (
A connection is incoming,VerifyPlayer - Serialising connection method: RocketNet) but never completes: the client stays atGameState.Joining/NetworkState.WaitingForConnectionfor at least 180 seconds with no further console output, on a client whose mod set is not identical to the server's. Whether this is a mod-list mismatch, a verify-handshake stall, or something specific to that server's state was not determined. The client side of the join was verified as far as the game itself controls. - Why the
System.Collections.Immutableload failed when the correct assembly was both shipped and loaded. It was neither a packaging bug nor a StationeersLaunchPad gap, which is what this entry used to ask: both mods shipped the exactly-correct 8.0.0.0 assembly and StationeersLaunchPad did load it, so the loaded copy was present in the domain when the reference failed to bind. Whether Mono suppresses the managedAssemblyResolveevent on the field-type-loading path, or caches the negative reference per image, is unresolved and needs a runtime probe rather than more decompiling. No longer answerable from this install as of 2026-08-13: the mods' 1.0.0.0 release binds the same assembly successfully, so the reproducer is gone and a fresh case is needed. Detail: Patterns/ModDependencyAssemblyResolution.md.