Printing to the in-game console (and where mod log lines actually go)¶
How a mod puts a message in front of a player, and the traps that make it go wrong. The API reference for the class itself, with verbatim decompiled bodies, is ConsoleWindow.
There are three separate "log" surfaces in a BepInEx-modded Stationeers, and they do not carry the same content:
| Surface | Fed by | A player typically looks here? |
|---|---|---|
BepInEx\LogOutput.log (disk) |
BepInEx ManualLogSource.LogInfo/LogWarning/LogError (i.e. Logger.Log* in a plugin). StationeersLaunchPad also mirrors mod log lines into Player.log. |
Power users / when sending a log to a mod author. |
Unity Player.log (%USERPROFILE%\AppData\LocalLow\Rocketwerkz\rocketstation\Player.log, rotated to Player-prev.log on each launch) |
UnityEngine.Debug.Log/LogWarning/LogError, plus (via StationeersLaunchPad's mirror) BepInEx mod log lines. |
Occasionally. |
The in-game console (ConsoleWindow, opened with KeyMap.ToggleConsole, F3 by default) |
ConsoleWindow.Print* calls, console-command output, and UnityEngine.Debug.LogError / LogException via the log bridge below. Not Debug.Log, not Debug.LogWarning, not BepInEx Logger.Log*. |
Yes, this is the one a player sees while playing. |
Key gotcha: Debug.Log(...) and Logger.LogInfo(...) write to their log files but do not appear in the in-game console, so a mod whose only output is those will look silent to a player who checks the console. To show up there, call ConsoleWindow.Print*.
The matching trap in the other direction: Debug.LogError and Debug.LogException DO appear in the in-game console. ConsoleWindow subscribes to Application.logMessageReceivedThreaded (Assembly-CSharp.decompiled.cs:221927) and its handler (:222266-222282) re-prints LogType.Error and LogType.Exception itself, lowercased, in red, prefixed with the uppercased level name in brackets ([ERROR] or [EXCEPTION]), followed by the stack trace. So calling Debug.LogError and a ConsoleWindow method for the same message shows it to the player twice. Pick one.
The five traps¶
Each of these produced a real defect in this repo, found in the 2026-07-27 audit.
-
agedis inverted from its name.aged: truesets the line's activeTime to 0, so it is NOT drawn on the closed-console overlay and appears only once the console is opened.aged: falseis what puts a line in front of a player who has not opened the console. PlainPrintdefaults toaged: true;PrintActionandPrintErroralready passfalse. -
PrintErrordumps a stack trace by default. Unless it is passedsuppressStacktrace: true, it prints a fullEnvironment.StackTraceas a second grey line. On an ordinary "you cannot do that" message this reads to a player as a mod crash. Always passsuppressStacktrace: truefor anything that is not a genuine unexpected failure, and prefer the BepInEx log for the diagnostic detail. -
There is no
PrintWarning. The three levels available arePrint(anyConsoleColor),PrintAction(Yellow), andPrintError(Red). A warning belongs inPrintAction, notPrintError. -
Main thread only. Every print shifts an unlocked 1024-entry static array while the draw loop reads it. Marshal first from any worker,
UniTask,FileSystemWatcher, or network-message thread. The game's own async print paths checkGameManager.IsThreadandawait UniTask.SwitchToMainThread()before printing. -
No rate limiting exists, and every print is O(1024). The console has no throttle, no dedupe, and no same-message collapse; each print runs the full ring-buffer shift. Self-limiting is entirely the caller's job. This matters most on per-tick, per-frame, and per-input-notch paths: one scroll-wheel flick is 10-20 notches.
Two more constraints that shape message text:
- No rich text. The console renders through ImGui
TextUnformatted, so TextMeshPro tags appear as literal characters. Worse, on a dedicated server without-logFile, any line containing<color=is silently discarded (:222987). Keep console text plain, and be careful with interpolated values that a player controls (a renameableDisplayName, chat text) or that a language model produced. - Process-local, not networked. A
Printon the server appears only on the server. To reach clients a mod must send its own message and print locally inProcess(); see ChatBroadcast for the replicating chat channel and theNetworkServer.SendToClient<T>unicast form.
Safe to call on every machine role including a headless dedicated server (the batch-mode branch writes stdout or the log and touches no UI) and before world load (early lines queue in _prematureLogQueue and replay at init).
Recommended mod pattern¶
Reference ConsoleWindow through a using alias rather than a blanket using Assets.Scripts;. The alias imports one name instead of the 145 top-level types in that namespace and states the fully-qualified target at the top of the file, so a namespace move at the next game update produces a precise error.
using ConsoleWindow = Assets.Scripts.ConsoleWindow; // top of the file, with the other usings
// Informational. Yellow, visible without opening the console, no stack trace.
internal static void PlayerLog(string msg) {
Plugin.Log?.LogInfo(msg); // -> LogOutput.log (+ Player.log via StationeersLaunchPad mirror)
UnityEngine.Debug.Log($"[ModName] {msg}"); // -> Player.log. LogType.Log is NOT re-printed, so no duplicate.
try { ConsoleWindow.PrintAction($"[ModName] {msg}", aged: false); } catch { }
}
// A warning is not an error. There is no PrintWarning; yellow is PrintAction.
internal static void PlayerWarn(string msg) {
Plugin.Log?.LogWarning(msg);
UnityEngine.Debug.LogWarning($"[ModName] {msg}"); // LogType.Warning is NOT re-printed either.
try { ConsoleWindow.PrintAction($"[ModName] {msg}", aged: false); } catch { }
}
// NOTE the absence of Debug.LogError. It would be re-printed by the console's own
// logMessageReceivedThreaded handler, so the player would see the line twice: once as this
// controlled PrintError, and again lowercased with an unavoidable stack trace. Nothing is lost --
// Log.LogError already reaches Player.log through the StationeersLaunchPad mirror.
internal static void PlayerError(string msg) {
Plugin.Log?.LogError(msg);
try { ConsoleWindow.PrintError($"[ModName] {msg}", suppressStacktrace: true); } catch { }
}
// Exception overload: full detail to the file log, type and message only to the player.
// Interpolating a bare {e} into a console line dumps a multi-line managed stack trace complete
// with compiler-generated frame names like <Postfix>b__0.
internal static void PlayerError(string msg, System.Exception e) {
Plugin.Log?.LogError($"{msg}: {e}");
try { ConsoleWindow.PrintError($"[ModName] {msg}: {e.GetType().Name}: {e.Message}", suppressStacktrace: true); } catch { }
}
The try/catch is for calls that can fire from Prefab.OnPrefabsLoaded, before the console UI exists. ConsoleWindow has an internal premature-log queue so it should be fine, but the catch costs nothing.
Self-limiting on repeatable paths. The console will not do it for you. Three patterns in use in this repo, in rough order of preference:
- Once per subject per session.
Mods/PowerGridPlus/PowerGridPlus/DeviceOutputSanitizer.cskeeps aConcurrentDictionary<long, byte>keyed by reference id and callsTryAddbefore printing, so each broken device is named exactly once and the set is cleared on world load. It also marshals throughUnityMainThreadDispatcherbecause the power tick runs on a worker, and degrades to the file log when no dispatcher exists. This is the reference implementation. - Hard cap with an overflow summary.
Mods/PowerGridPlus/PowerGridPlus/WreckageCleanup.csprints at mostAnnounceCap = 6lines and then one summary line for the remainder. - First occurrence only, rest to the file log.
Mods/NetworkPuristPlus/NetworkPuristPlus/ClampNegativeMergeQuantityPatch.csand the one-shot guards inCableRollOnConstruct.
For a loop over world content (every cable, every structure), prefer a count plus a summary line over one line per item. Mods/EquipmentPlus/EquipmentPlus/PlayerNotice.cs shows the input-path variant: a per-message cooldown, because the alternative is one line per scroll notch.
Message prefix. Use a bracketed mod tag so a player can tell whose message it is. Prefixes are inconsistent across this repo today ([EquipmentPlus], [NetworkPuristPlus], [Power Grid Plus], [PowerGridPlus], and PowerGridPlus's PlayerConsole messages carry none at all). The repo convention for player-facing text is the display name, so [Power Grid Plus] is the correct form of that pair; the code name is for machine-facing identifiers. Whichever a mod picks, it should use one form everywhere.
Reflection variant, only if you want zero compile-time dependency on Assembly-CSharp and graceful degradation if the class ever moves:
static MethodInfo _printAction, _printError; static bool _resolved;
static void ResolveConsole() {
if (_resolved) return; _resolved = true;
try {
var t = AccessTools.TypeByName("Assets.Scripts.ConsoleWindow")
?? AccessTools.TypeByName("ConsoleWindow")
?? AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => { try { return a.GetTypes(); } catch { return Type.EmptyTypes; } })
.FirstOrDefault(x => x.Name == "ConsoleWindow" && x.IsClass && x.IsAbstract && x.IsSealed);
if (t == null) return;
_printAction = AccessTools.Method(t, "PrintAction", new[] { typeof(string), typeof(bool) }) ?? AccessTools.Method(t, "PrintAction", new[] { typeof(string) });
_printError = AccessTools.Method(t, "PrintError", new[] { typeof(string), typeof(bool) }) ?? AccessTools.Method(t, "PrintError", new[] { typeof(string) });
} catch { }
}
Do not include "Util.Commands.ConsoleWindow" in the resolver. Util.Commands is a real namespace (the say / console-command classes live there) but ConsoleWindow is not in it; the lookup always fails and HarmonyX logs a Could not find type named Util.Commands.ConsoleWindow warning at startup.
The namespace question, and the CS0104 story¶
ConsoleWindow is in Assets.Scripts (class at :221811, inside the namespace Assets.Scripts block opened at :195722, next sibling namespace Assets.Scripts.Weather at :223978). A blanket using Assets.Scripts; plus a bare ConsoleWindow.PrintError(...) compiles.
This page previously claimed that a blanket using Assets.Scripts; risks CS0104 'Settings' is an ambiguous reference for a mod with its own Settings class. That claim was wrong and has been removed. There is exactly one type named Settings in the entire reference closure of these mod projects, and it is Assets.Scripts.Serialization.Settings (public class Settings : UserInterfaceBase, :265338, inside namespace Assets.Scripts.Serialization opened at :264409). Assets.Scripts contains no Settings at all, so there is only ever one candidate and CS0104 cannot fire. Independently, even with two candidates C# resolves a name in the enclosing namespace before consulting that namespace's using-directives, so a mod's own Settings would win regardless. Six files in this repo already combine using Assets.Scripts; with an unqualified reference to their own Settings and are present in shipped DLLs.
The alias recommendation above still stands, on the honest grounds (one imported name instead of 145, and a precise error if the type moves) rather than on the CS0104 grounds.
Where real ambiguity exposure in this codebase actually comes from: Assembly-CSharp ships parallel namespace pairs, both Objects and Assets.Scripts.Objects, both Networks and Assets.Scripts.Networks, both Networking and Assets.Scripts.Networking, both Util and Assets.Scripts.Util, both UI and Assets.Scripts.UI, both Weather and Assets.Scripts.Weather. Mods/PowerGridPlus/PowerGridPlus/VoltageTier.cs already imports Assets.Scripts.Objects, Objects, and Objects.Rockets together.
Verification history¶
- 2026-07-29: conflict on "what bracketed prefix does the log bridge put in front of a re-printed message". Previous claim (line 33): the handler re-prints
LogType.ErrorandLogType.Exception"lowercased, prefixed[ERROR]". New finding: the prefix isEnumCollections.LogTypes.GetName(type).ToUpper(), and that collection is constructed withtoProper: false, so the names stay as rawEnum.GetNamesoutput andLogType.Exceptionrenders[EXCEPTION], not[ERROR]. Fresh validator verdict: B is correct, quotingLogMessageat:222266-222282(string text = EnumCollections.LogTypes.GetName(type).ToUpper();) and theEnumCollection<T1,T2>constructor at:203609-203619, whereNames = Enum.GetNames(typeof(T1))is only rewritten byToProper()insideif (toProper). The validator confirmed the field isstatic readonlyand never reassigned (:203493and:222272are its only references), that no post-construction write toNamesexists, thatGetNameFromIndexisvirtualbut has no subclass anywhere in the assembly, and thatpaddeddefaults tofalseso the space-paddedPaddedNamesis not in play (padded: truewould have produced[ERROR ]). Everything else in the old sentence (lowercasing, red, theError || Exceptiongate, the trailing stack trace) was accurate. Result: the "matching trap in the other direction" paragraph now names both tags. The parallel bullet on ConsoleWindow said only "e.g.[ERROR]", which was hedged rather than wrong; it was expanded with the same derivation so the two pages cannot drift apart again. Found while reviewing the console-output fix commits, which had copied the[ERROR]generalisation out of this page and into a mod code comment. - 2026-07-27: conflict on "does ConsoleWindow subscribe to Unity's log callback". Previous claim (line 26): "Stationeers'
ConsoleWindowdoes not subscribe toApplication.logMessageReceived", sourced to.work/decomp/0.2.6228.27061/Assembly-CSharp.decompiled.cs:206094-206957(a decompile folder no longer on disk). New finding: it subscribes toApplication.logMessageReceivedThreadedand re-printsLogType.ErrorandLogType.Exception. Fresh validator verdict: B is correct, quoting_Initat:221924-221928and theLogMessagehandler at:222266-222282; the non-threaded event is never subscribed anywhere in the assembly, which is why the old claim was literally true aboutlogMessageReceivedwhile being substantively wrong about the outcome. The validator also determined the claim was wrong when written rather than changed between versions (high confidence):GameLoggingSinks.md:41, written 2026-06-24 against the same 0.2.6228.27061 decompile, already documented the subscription atConsoleWindow._Init line 206182, and its several independent line citations reconstruct the old_Initwith internal offsets (+7, +9) that match today's exactly, so the old_Initwas structurally identical. Result: the summary table row and the "Key gotcha" paragraph rewritten to state thatDebug.LogError/LogExceptionDO reach the console; the recommendedPlayerErrorhelper no longer pairsDebug.LogErrorwithPrintError. That pairing was the direct cause of a double-print defect inMods/NetworkPuristPlus/NetworkPuristPlus/Plugin.cs, which this page had taught. - 2026-07-27: conflict on "does a blanket
using Assets.Scripts;risk CS0104 against a mod's ownSettings". Previous claim (lines 47 and 90): yes, becauseAssets.ScriptscontainsSettings : UserInterfaceBase. New finding: no such type exists inAssets.Scripts; it isAssets.Scripts.Serialization.Settings, and enclosing-namespace resolution would win anyway. Fresh validator verdict: B is correct, quotingnamespace Assets.Scripts.Serializationat:264409withpublic class Settings : UserInterfaceBaseat:265338; exactly oneSettingsexists in the whole reference closure of both mod projects, and CS0104 requires two candidates. The validator noted the old page refutes itself: line 90 placesAssets.Scriptsas closed by ~207612 in the old dump while the frontmatter placedSettingsat 248232, some 40,000 lines later. The originally observed compile failure was almost certainlyCS0246from theusing Util.Commands;attempt recorded in the same history entry, notCS0104. Result: the CS0104 rationale removed from the "Recommended mod pattern" section and replaced by a dedicated section stating the correct facts; the alias recommendation kept on other grounds. The frontmatter source entry forSettingswas corrected fromAssets.Scripts.SettingstoAssets.Scripts.Serialization.Settings. - 2026-07-27: re-verified and restamped against 0.2.6403.27689. All cited line numbers from the 0.2.6228.27061 era had drifted and no longer resolved (the old decompile folder is gone). The API detail that had accumulated on this page moved to the new ConsoleWindow game-class page, with this page keeping the mod-facing guidance. Added, all newly verified: the inverted
agedsemantics,PrintError's default stack-trace dump, the absence ofPrintWarning, the main-thread-only constraint, the absence of any rate limiting, the ImGui / no-rich-text constraint and the dedicated-server<color=drop, and the process-local (non-networked) nature of the console. Added the self-limiting patterns section citing the three in-repo implementations. - 2026-05-11: page created after a NetworkPuristPlus user reported "I see nothing in the player log" despite the mod's
Logger.LogInfolines being present in bothLogOutput.logandPlayer.log-- they were looking at the in-game console, which neitherLogger.Log*norDebug.Logreaches.ConsoleWindow.PrintAction/PrintErrorsignatures lifted from.work/decomp/0.2.6228.27061/Assembly-CSharp.decompiled.cs(around line 206094, 206824-206957). Namespace ofConsoleWindowcould not be pinned from the dump at the time (theawk"lastnamespacebefore the class" heuristic saidAssets.Scripts; a brace-depth tracker said global -- unreliable because decompiled$"..."interpolation throws off naive{ }counting; a directusing Util.Commands;failed to compile) -- the page recommended the reflection-based resolver and listed the namespace as an open question. - 2026-05-12: namespace resolved to
Assets.Scripts. Confirmed two ways: (1) in.work/decomp/0.2.6228.27061/Assembly-CSharp.decompiled.csthepublic static class ConsoleWindowat ~206094 sits inside thenamespace Assets.Scriptsblock opened at ~184063 with no interveningnamespacedeclaration before the next siblingnamespace Assets.Scripts.Weatherat ~207612; (2)EquipmentPluscompiles cleanly withusing Assets.Scripts;+ a bareConsoleWindow.PrintError(...)(Mods/EquipmentPlus/EquipmentPlus/HelmetBeamPatches.cs:123,ScrollDispatchPatches.cs:271; builtEquipmentPlus.dllpresent). The earlier "a directusingfailed to compile" was specificallyusing Assets.Scripts;colliding with a same-namedSettingsclass in the caller (NetworkPuristPlus.SettingsvsAssets.Scripts.Settings), not aConsoleWindow-name problem -- the fix is ausing ConsoleWindow = Assets.Scripts.ConsoleWindow;alias.ConsoleWindow APIandRecommended mod patternsections rewritten accordingly;Util.Commands.ConsoleWindowremoved from the suggested reflection chain (it never matched). No fresh-validator pass needed: the contradicted claim ("namespace unknown / ausingfails") is overturned by a strictly stronger source (the decompilednamespaceblock plus a compiling counter-example mod), with no ambiguity to resolve. (TheSettings-collision half of this entry was itself overturned on 2026-07-27; see above.)
Open questions¶
None.