Save console protocol¶
Which console lines a world save emits, in what order, and which single line means the save actually succeeded. Written for tooling that drives a save and then greps a log to decide whether it completed, so the exact format strings matter and are quoted verbatim.
The short version: Starting <Method> for <name> means the request was ACCEPTED, not that it worked. Only Saved <name> (existing save) or Created new save (first save under that name) is a completion signal, and both are printed by the command layer, never by SaveHelper itself.
The command chain¶
Assets.Scripts.ConsoleWindow.Submit(string) (:222487-222491) is a direct pass into the command dispatcher, so any code that can call one method on the Unity main thread can drive a save:
public static void Submit(string input)
{
_consoleInput = input;
Submit();
}
private static void Submit()
{
_snapNextFrame = true;
_lastTabResult = string.Empty;
_tabMatches.Clear();
if (string.IsNullOrEmpty(_consoleInput))
{
Hide();
return;
}
Print(_consoleInput, ConsoleColor.Cyan, clearLine: false, aged: true, unformatted: true);
CommandLine.Process(_consoleInput);
_clearConsoleInput = true;
}
Note the echo: the submitted text itself is printed Cyan before dispatch, so save "Foo" appears in the log as its own line ahead of any save output.
SaveCommand (Util.Commands.SaveCommand, :98726) dispatches:
public override string Execute(string[] args)
{
if (!EnforceScope("save"))
{
return null;
}
if (args.Length == 0)
{
return CreateSave(XmlSaveLoad.Instance.CurrentStationName);
}
switch (args[0])
{
case "delete":
case "rm":
case "d":
return DeleteSave(args);
case "list":
case "l":
return ListSaves();
default:
return CreateSave(args[0]);
}
}
CreateSave (:98760) is the fork between the new-save and existing-save paths:
public static string CreateSave(string saveName)
{
GameState gameState = GameManager.GameState;
if (gameState != GameState.Running && gameState != GameState.Paused)
{
ConsoleWindow.PrintError($"Cannot save game in GameState '{GameManager.GameState}'.", suppressStacktrace: true);
return null;
}
if (!FileCommand.GetStationDirectory(saveName).Exists)
{
NewSaveTask(saveName).Forget();
return null;
}
SaveTask(saveName).Forget();
return null;
}
FileCommand.GetStationDirectory (:97884) is a pure path build, no I/O beyond the existence test the caller does:
public static DirectoryInfo GetStationDirectory(string folderName)
{
return new DirectoryInfo($"{StationSaveUtils.GetSavePathSavesSubDir()}/{folderName}");
}
Both tasks are fire-and-forget (.Forget()), so Execute returns null immediately and every save line arrives asynchronously afterwards.
private static async UniTaskVoid SaveTask(string stationName) // :98777
{
SaveResult saveResult = await SaveHelper.Save(stationName, default(CancellationToken));
if (saveResult.Success)
{
ConsoleWindow.Print("Saved " + stationName);
}
else
{
ConsoleWindow.PrintError(saveResult.Message, suppressStacktrace: true);
}
}
private static async UniTaskVoid NewSaveTask(string stationName) // :98790
{
stationName = SaveHelper.SanitizeSaveName(stationName);
SaveResult saveResult = await SaveHelper.NewSave(stationName, default(CancellationToken));
if (saveResult.Success)
{
XmlSaveLoad.Instance.CurrentStationName = stationName;
ConsoleWindow.Print("Created new save");
}
else
{
ConsoleWindow.PrintError(saveResult.Message, suppressStacktrace: true);
}
}
Asymmetry worth knowing: only the new-save path runs SanitizeSaveName and only the new-save path assigns XmlSaveLoad.Instance.CurrentStationName. A save "<existing>" never touches the current station name.
public static string SanitizeSaveName(string saveName) // :264824
{
string pattern = "[?:*<>|\\\\/\"]";
return Regex.Replace(saveName, pattern, "_");
}
Because the directory-existence test runs on the RAW argument and the sanitize runs afterwards, a name containing any of those characters can test as "does not exist", get sanitized to a name that DOES exist, and then fail inside DoNewSave with Save Failed: Could not create save directory.
Both SaveHelper.Save and SaveHelper.NewSave are one-line wrappers onto the single dispatcher SaveHelper.SaveGame (:264835):
public static async UniTask<SaveResult> NewSave(string stationName, CancellationToken cancellationToken)
{
return await SaveGame(SaveMethod.NewSave, stationName, null, cancellationToken);
}
public static async UniTask<SaveResult> Save(string stationName, CancellationToken cancellationToken)
{
return await SaveGame(SaveMethod.Save, stationName, null, cancellationToken);
}
The routing below SaveGame (Do*Save into the private Save(DirectoryInfo, string, bool, CancellationToken) worker at :264972) is documented in SaveZipExtension.
Console lines, verbatim and in order¶
Head of SaveHelper.SaveGame (:264835-264848):
private static async UniTask<SaveResult> SaveGame(SaveMethod saveMethod, string stationName, string saveFileName, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(stationName))
{
return SaveResult.Fail("Save Failed: Folder name is empty.");
}
if (IsSaving)
{
return SaveResult.Fail("Save Failed: Already saving.");
}
ConsoleWindow.Print($"Starting {saveMethod} for {stationName}");
SaveResult result = await PrepareToSave(cancellationToken);
...
saveMethod is the SaveMethod enum (:264704), so the interpolation renders the enum member name:
Every line a save can produce:
| Line (verbatim format) | Emitter | Meaning |
|---|---|---|
Starting Save for <name> |
SaveHelper.SaveGame :264844 |
Request accepted. NOT a success. |
Starting NewSave for <name> |
SaveHelper.SaveGame :264844 |
Same, on the first-save-under-this-name path. |
Starting AutoSave for <name> |
SaveHelper.SaveGame :264844 |
Same, from the autosave timer or the last-client-leave hook. |
Starting QuickSave for <name> / Starting SaveAs for <name> |
SaveHelper.SaveGame :264844 |
Same, from the quick-save and save-as paths. |
Saving - game tick paused in <n>ms |
PrepareToSave :264954 |
Progress. Simulation is now parked. |
Saving - atmospheres cleaned up in <n>ms |
PrepareToSave :264960 |
Progress. |
Saving - got world data in <n>ms |
private Save :264986 |
Progress. |
Saving - unpausing game tick |
private Save :264993 |
Progress. Simulation resumes BEFORE the ZIP is written. |
Saving - created preview images in <n>ms |
private Save :265003 |
Progress. Non-batch only (if (!GameManager.IsBatchMode)), so a dedicated server never prints it. |
Saving - serialized, zipped and file created in <n>ms |
private Save :265038 |
Last progress line. The .save file is on disk at this point, but the result has not yet reached the caller. |
Saved <name> |
SaveCommand.SaveTask :98782 |
SUCCESS. Only printed after SaveResult.Success. |
Created new save |
SaveCommand.NewSaveTask :98797 |
SUCCESS on the first save under that name. Carries no name. |
Cannot save game in GameState '<state>'. |
SaveCommand.CreateSave :98765 |
Rejected before any save work. PrintError. |
Save Failed: Folder name is empty. |
SaveGame :264839 |
Rejected. Reaches the console through the caller's PrintError. |
Save Failed: Already saving. |
SaveGame :264843 |
Rejected, a save is in flight. |
Save Failed: Game must be running to save |
PrepareToSave :264947 |
Second GameState check, inside the helper. Note: no trailing period. |
Save Failed: Save type invalid |
SaveGame :264859 |
Unreachable from the console; SaveMethod.Uninitialised only. |
Save Failed: Could not create save directory. |
DoNewSave :264869 |
New-save path, directory already exists or could not be made. |
Save Failed: Could not find directory with name <name>. |
DoSave :264879, DoSaveAs :264897 |
|
Save Failed: Failed to get head save at <fullpath>. |
DoSave :264883, DoSaveAs :264902 |
The folder holds zero *.save files. |
Save Failed: Save file name is empty. |
DoSaveAs :264893 |
|
Save Failed: File name <name> is not unique. |
DoSaveAs :264906 |
|
Failed to write save file at path <path> : <exception> |
private Save :265031 |
The ZIP write itself threw. |
Every Save Failed: ... string and the Failed to write save file ... string is a SaveResult.Message, not a direct print. It reaches the console only because a caller passes it to ConsoleWindow.PrintError(saveResult.Message, suppressStacktrace: true). A caller that discards the result prints nothing at all (see the last-client-leave hook below).
Two grep hazards:
Starting AutoSave for <name>contains the substringSave for, so a loose pattern such asSave fororStarting.*Save formatches autosaves as well as manual saves. Match the exact prefixStarting Save forwhen the intent is "the manual save I asked for".- On a batch-mode process every one of these lines is prefixed with a timestamp by
ConsoleWindow.Print(output = $"{DateTime.Now:HH:mm:ss}: {output}"), so anchor patterns to the end or the middle of the line, not to the start. See ConsoleWindow.
Autosave cannot produce a false confirmation¶
Neither autosave path can print Saved <name> or Created new save, so a harness that waits for one of those two lines cannot be fooled by an autosave that happens to land during the wait.
There are exactly two autosave drivers, and both call SaveHelper.AutoSave directly rather than going through SaveCommand.
Timer-driven, StationAutoSave.AutoSaveTask (:267620-267627):
private static async UniTaskVoid AutoSaveTask()
{
SaveResult saveResult = await SaveHelper.AutoSave(XmlSaveLoad.Instance.CurrentStationName, default(CancellationToken));
if (!saveResult.Success)
{
ConsoleWindow.PrintError(saveResult.Message);
}
}
Only the failure branch prints. See StationAutoSave for the timer, the fire-time gates, and the Save Failed: Folder name is empty. loop a fresh -new world produces.
Last-client-leave, NetworkBase.AutoSaveOnLastClientLeave (:39256-39268), reached from OnClientRemoved (:39247) when GameManager.IsBatchMode && Settings.CurrentData.AutoPauseServer && Clients.Count <= 0:
private static async UniTaskVoid AutoSaveOnLastClientLeave(CancellationToken cancellationToken)
{
ConsoleWindow.PrintAction("No clients connected. Will save and pause in 10 seconds.");
await UniTask.Delay(10000, ignoreTimeScale: false, PlayerLoopTiming.Update, cancellationToken);
if (!cancellationToken.IsCancellationRequested)
{
await SaveHelper.AutoSave(XmlSaveLoad.Instance.CurrentStationName, cancellationToken);
if (!cancellationToken.IsCancellationRequested)
{
ConsoleWindow.PrintAction("Server Paused");
WorldManager.SetGamePause(pauseGame: true);
}
}
}
This one discards the SaveResult entirely, so it prints neither a success nor a failure line. Its two observable markers are No clients connected. Will save and pause in 10 seconds. and Server Paused, plus the Starting AutoSave for <name> line from SaveGame. A failed save here is completely silent, and Server Paused still prints.
What autosave DOES share with a manual save: the Starting AutoSave for ... line and every Saving - ... progress line, because all of them live in SaveGame / PrepareToSave / the private Save. Only the terminal confirmation differs.
Other emitters of the same two success lines¶
Saved <name> and Created new save are not unique to SaveCommand. Both strings appear at four sites each, all gated on saveResult.Success:
| Line | Site | Reached by |
|---|---|---|
Saved <name> |
SaveCommand.SaveTask :98782 |
save [name] |
Saved <name> |
FileCommand.SaveTask :98024 |
file save |
Saved <name> |
FileCommand.SaveAsTask :98056 |
file saveas <savename> |
Saved <name> |
FileCommand.QuickSaveTask :98084 |
file quicksave |
Created new save |
SaveCommand.NewSaveTask :98797 |
save <newname> |
Created new save |
FileCommand.NewSaveTask :97996 |
file new <stationname> |
Created new save |
FileCommand.NewGameTask :97967 |
file start <stationname> ... |
Created new save |
LoadGameCommand.NewGameTask :98931 |
load <name> <worldid> where <name> does not resolve to an existing save |
The last row is the one that surprises: a -load <SaveName> <Map> launch that falls through to starting a new world finishes by saving and printing Created new save, preceded by Started new game. Saving... (ConsoleWindow.PrintAction, :98925). So on a dedicated server, Created new save is a normal world-creation line, not necessarily a response to a save command.
Two other Saved <name> strings exist in the assembly and are unrelated to world saves: Saved <x> successfully at :61619 and :448833. Anchoring on the exact "Saved " + stationName form avoids both.
FileCommand also carries forceallowsave (:97851 dispatch table), which calls the SaveHelper.ForceIsSavingToFalse() escape hatch (:264830) for a stuck Save Failed: Already saving. state.
Where the file lands, and the head-save rule¶
DoNewSave (:264864) names the file stationName + ".save" inside a freshly created station directory. DoSave (:264874) instead overwrites the existing HEAD save, whose name it resolves through GetHeadSave (:265106):
private static bool GetHeadSave(DirectoryInfo stationDirectory, out FileInfo headSaveFile)
{
FileInfo[] files = stationDirectory.GetFiles(SaveLoadConstants.SaveFileSearchPattern);
if (files.Length < 1)
{
headSaveFile = null;
return false;
}
headSaveFile = files[0];
return true;
}
files[0] with no sort. DirectoryInfo.GetFiles ordering is not guaranteed by the framework, so a station folder containing more than one top-level *.save file has an undefined head. The only guard is Length < 1, which yields Save Failed: Failed to get head save at <fullpath>. Keep exactly one *.save at the top level of a station folder; the rolling autosave and quicksave files live in the AutoSaveFolder / QuickSaveFolder subdirectories created by CreateSaveDirectory (:264782), not next to the head save.
This is the same one-file invariant the load command enforces from the other side, documented in SaveZipExtension under "Save folder layout and the load command's resolution".
Scope: save is refused on a remote client¶
SaveCommand.Scope => CommandScope.HostOrSinglePlayer (:98734), and the first statement of Execute is if (!EnforceScope("save")) return null;.
public enum CommandScope // :96671
{
None = 0,
InGame = 1,
HostOrSinglePlayer = 2,
MultiplayerOnly = 4,
SinglePlayerOnly = 8,
CreativeOnly = 0x10
}
protected bool EnforceScope(string key) // :96702
{
CommandScope scope = Scope;
if ((scope & CommandScope.HostOrSinglePlayer) != CommandScope.None && CannotAsClient(key))
{
return false;
}
if ((scope & CommandScope.MultiplayerOnly) != CommandScope.None && CannotInSinglePlayer(key))
{
return false;
}
if ((scope & CommandScope.SinglePlayerOnly) != CommandScope.None && (Assets.Scripts.Networking.NetworkManager.IsClient || Assets.Scripts.Networking.NetworkManager.IsServer))
{
ConsoleWindow.PrintError("cannot use '" + key + "' while in multiplayer", suppressStacktrace: true);
return false;
}
...
protected static bool CannotAsClient(string commandKey) // :96839
{
if (Assets.Scripts.Networking.NetworkManager.IsActiveAsClient)
{
ConsoleWindow.PrintError(ConsoleStrings.Error.CannotAsClient.AsString(commandKey.ToLower()), suppressStacktrace: true);
return true;
}
return false;
}
The predicate is NetworkManager.IsActiveAsClient. A listen host and a single-player session are both allowed; only a process joined to someone else's server is refused. The rejection message is a localized ConsoleStrings.Error.CannotAsClient template with the lower-cased command key substituted in, so its English text is not a compile-time constant and should not be matched on. FileCommand.Execute (:97850) applies the same gate by calling CommandBase.CannotAsClient("save") directly rather than through EnforceScope.
To make a joined client save, route the command through serverrun so the host runs it; see DedicatedServerSettings.
Verification history¶
- 2026-08-15: the Open Question "whether stdin console commands reach
CommandLine.Processon a batch-mode dedicated server" is resolved: they do not, and there is no configuration in which they do. Determined at 0.2.6428.27798 by a fresh validator underResearch/WORKFLOW.mdRule 3. The server's console reader isUI.ImGuiUi.RocketSystemConsole.ConsoleInputThread, which callsSystem.Console.ReadKey()against the Win32 console input buffer; it is wired to the dispatcher by_systemConsoleInput.OnInputReceived += CommandLine.Process;and never reads a stdin stream. Writing a line into the process's redirected stdin therefore has no consumer, measured both with-logFilepresent (the console object is not even constructed) and with-logFileremoved (console fully up,Readyprinted, input thread started, and the write still did nothing). The chain documented on this page is unaffected: the transport was the only thing in question, andConsoleWindow.Submitremains the in-process entry point that drives a save. Full evidence: DedicatedServerSettings, "The console channel". - 2026-08-09: page created. Every format string, line number and code excerpt read directly from
.work/decomp/0.2.6403.27689/Assembly-CSharp.decompiled.csin this pass. Established thatStarting <Method> for <name>is an acceptance marker rather than a success marker, thatSaved <name>andCreated new saveare the only completion signals and are printed exclusively by the command layer afterSaveResult.Success, and that neither autosave driver (StationAutoSave.AutoSaveTask,NetworkBase.AutoSaveOnLastClientLeave) can print either one. Additive page: no existing verified claim was changed. Two corrections to the source material this page was written from are recorded here rather than silently dropped:SaveHelper.SaveGameis at :264835, not :264979 (that line is inside the privateSaveworker); andSaveCommand.CreateSavereachesSaveGamethroughSaveHelper.Save/SaveHelper.NewSave, not directly.
Open questions¶
- Whether
DirectoryInfo.GetFilesreturns entries in a stable order on NTFS in practice.GetHeadSavetakesfiles[0]with no sort, so a station folder with two top-level*.savefiles has an undefined head. Not worth relying on either way; keep one file. (The stdin transport question that sat here is resolved; see the 2026-08-15 Verification History entry above.)