Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ ipch/
*.opensdf
*.sdf
*.cachefile
*.lscache
*.VC.db
*.VC.VC.opendb

Expand Down
13 changes: 11 additions & 2 deletions Source/Client/AsyncTime/AsyncWorldTimeComp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ public void ExecuteCmd(ScheduledCommand cmd)

if (cmdType == CommandType.CreateJoinPoint)
{
if (Multiplayer.session?.ConnectedToStandaloneServer == true && !TickPatch.currentExecutingCmdIssuedBySelf)
return;

LongEventHandler.QueueLongEvent(CreateJoinPointAndSendIfHost, "MpCreatingJoinPoint", false, null);
}

Expand Down Expand Up @@ -275,9 +278,15 @@ private static void CreateJoinPointAndSendIfHost()
{
Multiplayer.session.dataSnapshot = SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveAndReload(), Multiplayer.GameComp.multifaction);

if (!TickPatch.Simulating && !Multiplayer.IsReplay &&
(Multiplayer.LocalServer != null || Multiplayer.arbiterInstance))
if (!TickPatch.Simulating && !Multiplayer.IsReplay)
SaveLoad.SendGameData(Multiplayer.session.dataSnapshot, true);

// When connected to a standalone server, upload fresh snapshots
if (!TickPatch.Simulating && !Multiplayer.IsReplay && Multiplayer.session?.ConnectedToStandaloneServer == true)
{
SaveLoad.SendStandaloneMapSnapshots(Multiplayer.session.dataSnapshot);
SaveLoad.SendStandaloneWorldSnapshot(Multiplayer.session.dataSnapshot);
}
}

public void SetTimeEverywhere(TimeSpeed speed)
Expand Down
17 changes: 17 additions & 0 deletions Source/Client/ConstantTicker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ private static void TickNonSimulation()

private static void TickAutosave()
{
// Only standalone connections use the synthetic autosave timer.
if (Multiplayer.session?.ConnectedToStandaloneServer == true)
{
var session = Multiplayer.session;
if (session.autosaveUnit != AutosaveUnit.Minutes || session.autosaveInterval <= 0)
return;

session.autosaveCounter++;

if (session.autosaveCounter > session.autosaveInterval * TicksPerMinute)
{
session.autosaveCounter = 0;
Autosaving.DoAutosave();
}
return;
}

if (Multiplayer.LocalServer is not { } server) return;

if (server.settings.autosaveUnit == AutosaveUnit.Minutes)
Expand Down
5 changes: 3 additions & 2 deletions Source/Client/MultiplayerGame.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,10 @@ public void ChangeRealPlayerFaction(int newFaction)

public void ChangeRealPlayerFaction(Faction newFaction, bool regenMapDrawers = true)
{
Log.Message($"Changing real player faction to {newFaction} from {myFaction}");

myFaction = newFaction;
if (Find.FactionManager != null)
Find.FactionManager.ofPlayer = newFaction;

FactionContext.Set(newFaction);
worldComp.SetFaction(newFaction);

Expand Down
1 change: 1 addition & 0 deletions Source/Client/Networking/State/ClientBaseState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public void HandleKeepAlive(ServerKeepAlivePacket packet)
[TypedPacketHandler]
public void HandleTimeControl(ServerTimeControlPacket packet)
{
if (Multiplayer.session == null) return;
if (Multiplayer.session.remoteTickUntil >= packet.tickUntil) return;

TickPatch.serverTimePerTick = packet.serverTimePerTick;
Expand Down
4 changes: 4 additions & 0 deletions Source/Client/Networking/State/ClientJoiningState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ public override void StartState()
[TypedPacketHandler]
public void HandleProtocolOk(ServerProtocolOkPacket packet)
{
Multiplayer.session.isStandaloneServer = packet.isStandaloneServer;
Multiplayer.session.autosaveInterval = packet.autosaveInterval;
Multiplayer.session.autosaveUnit = packet.autosaveUnit;

if (packet.hasPassword)
{
// Delay showing the window for better UX
Expand Down
33 changes: 30 additions & 3 deletions Source/Client/Patches/Designators.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,16 @@ public static class DesignatorPatches
{
public static bool DesignateSingleCell(Designator __instance, IntVec3 __0)
{
if (!Multiplayer.InInterface) return true;
if (!Multiplayer.InInterface)
{
Log.WarningOnce(
"Multiplayer: blocked DesignateSingleCell because InInterface=false " +
$"designator={__instance.GetType().Name}, reloading={Multiplayer.reloading}, ticking={Multiplayer.Ticking}, " +
$"executingCmds={Multiplayer.ExecutingCmds}, simulating={TickPatch.Simulating}, frozen={TickPatch.Frozen}, " +
$"hasCurrentEvent={LongEventHandler.currentEvent != null}, programState={Current.ProgramState}",
18000101);
return true;
}
Comment on lines +28 to +37
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning message says the action was "blocked", but this prefix returns true, which allows the original DesignateSingleCell to run (i.e., it isn’t blocked). This is misleading when diagnosing desyncs.

Either change the log text to reflect that sync interception was skipped (not blocked), or return false if the designator really should be blocked when InInterface is false.

Copilot uses AI. Check for mistakes.

Designator designator = __instance;

Expand All @@ -44,7 +53,16 @@ public static bool DesignateSingleCell(Designator __instance, IntVec3 __0)

public static bool DesignateMultiCell(Designator __instance, IEnumerable<IntVec3> __0)
{
if (!Multiplayer.InInterface) return true;
if (!Multiplayer.InInterface)
{
Log.WarningOnce(
"Multiplayer: blocked DesignateMultiCell because InInterface=false " +
$"designator={__instance.GetType().Name}, reloading={Multiplayer.reloading}, ticking={Multiplayer.Ticking}, " +
$"executingCmds={Multiplayer.ExecutingCmds}, simulating={TickPatch.Simulating}, frozen={TickPatch.Frozen}, " +
$"hasCurrentEvent={LongEventHandler.currentEvent != null}, programState={Current.ProgramState}",
18000102);
return true;
}
Comment on lines +56 to +65
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as above: log says "blocked" but returning true allows the original DesignateMultiCell to run. This message is misleading for debugging.

Adjust the message or change the return value depending on the intended behavior.

Copilot uses AI. Check for mistakes.

// No cells implies Finalize(false), which currently doesn't cause side effects
if (!__0.Any()) return true;
Expand All @@ -70,7 +88,16 @@ public static bool DesignateMultiCell(Designator __instance, IEnumerable<IntVec3

public static bool DesignateThing(Designator __instance, Thing __0)
{
if (!Multiplayer.InInterface) return true;
if (!Multiplayer.InInterface)
{
Log.WarningOnce(
"Multiplayer: blocked DesignateThing because InInterface=false " +
$"designator={__instance.GetType().Name}, thing={__0}, reloading={Multiplayer.reloading}, ticking={Multiplayer.Ticking}, " +
$"executingCmds={Multiplayer.ExecutingCmds}, simulating={TickPatch.Simulating}, frozen={TickPatch.Frozen}, " +
$"hasCurrentEvent={LongEventHandler.currentEvent != null}, programState={Current.ProgramState}",
18000103);
return true;
}
Comment on lines +91 to +100
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as above: log says "blocked" but returning true allows the original DesignateThing to run. This message is misleading for debugging.

Adjust the message or change the return value depending on the intended behavior.

Copilot uses AI. Check for mistakes.

Designator designator = __instance;

Expand Down
2 changes: 1 addition & 1 deletion Source/Client/Patches/GravshipTravelSessionPatches.cs
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ static bool Prefix(ref AcceptanceReport __result)
}

// Not in a landing session, use vanilla logic for player control
__result = Current.Game.PlayerHasControl;
__result = true;
return false;
Comment on lines +251 to 252
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "use vanilla logic for player control", but the code forces __result = true and skips the original getter. This changes behavior beyond the landing-session message suppression and may re-enable control in situations where vanilla would intentionally disable it.

If the intent is to keep vanilla behavior outside landing confirmation, return true to run the original getter (or set __result = Current.Game.PlayerHasControl). If the intent is to always allow control, update the comment to match.

Suggested change
__result = true;
return false;
return true;

Copilot uses AI. Check for mistakes.
}

Expand Down
11 changes: 10 additions & 1 deletion Source/Client/Patches/TickPatch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,18 @@ private static bool RunCmds()

foreach (ITickable tickable in AllTickables)
{
while (tickable.Cmds.Count > 0 && tickable.Cmds.Peek().ticks == curTimer)
while (tickable.Cmds.Count > 0 && tickable.Cmds.Peek().ticks <= curTimer)
{
ScheduledCommand cmd = tickable.Cmds.Dequeue();

if (cmd.ticks < curTimer && Prefs.DevMode)
{
Log.Warning(
"Multiplayer: executing stale queued command after reconnect/latency " +
$"type={cmd.type}, mapId={cmd.mapId}, cmdTicks={cmd.ticks}, timer={curTimer}, " +
$"factionId={cmd.factionId}, playerId={cmd.playerId}");
}

// Minimal code impact fix for #733. Having all the commands be added to a single queue gets rid of
// the out-of-order execution problem. With a proper fix, this can be reverted to tickable.ExecuteCmd
var target = TickableById(cmd.mapId);
Expand Down
5 changes: 5 additions & 0 deletions Source/Client/Patches/VTRSyncPatch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using HarmonyLib;
using Multiplayer.Client.Util;
using Multiplayer.Common;
using Multiplayer.Common.Networking.Packet;
using RimWorld.Planet;
using Verse;

Expand Down Expand Up @@ -142,6 +143,10 @@ static void Postfix(WorldRenderMode __result)
{
VTRSync.SendViewedMapUpdate(VTRSync.lastMovedToMapId, VTRSync.WorldMapId);
}

// On standalone, trigger a join point when leaving a map
if (Multiplayer.session?.ConnectedToStandaloneServer == true)
Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.WorldTravel));
}
// Detect transition back to tile map
else if (__result != WorldRenderMode.Planet && lastRenderMode == WorldRenderMode.Planet)
Expand Down
58 changes: 58 additions & 0 deletions Source/Client/Saving/SaveLoad.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using Ionic.Zlib;
using Multiplayer.Common;
using Multiplayer.Common.Networking.Packet;
using RimWorld;
using RimWorld.Planet;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Xml;
using Multiplayer.Client.Saving;
Expand Down Expand Up @@ -240,6 +242,62 @@ void Send()
else
Send();
}

/// <summary>
/// Send per-map standalone snapshots to the server for all maps in the given snapshot.
/// Called after autosave when connected to a standalone server.
/// </summary>
public static void SendStandaloneMapSnapshots(GameDataSnapshot snapshot)
{
var tick = snapshot.CachedAtTime;

foreach (var (mapId, mapBytes) in snapshot.MapData)
{
var compressed = GZipStream.CompressBuffer(mapBytes);

byte[] hash;
using (var sha = SHA256.Create())
hash = sha.ComputeHash(compressed);

var packet = new ClientStandaloneMapSnapshotPacket
{
mapId = mapId,
tick = tick,
leaseVersion = 0, // First iteration: no lease negotiation
mapData = compressed,
sha256Hash = hash,
};

OnMainThread.Enqueue(() => Multiplayer.Client?.SendFragmented(packet.Serialize()));
}
}

/// <summary>
/// Send the world + session standalone snapshot to the server.
/// Called after autosave when connected to a standalone server.
/// </summary>
public static void SendStandaloneWorldSnapshot(GameDataSnapshot snapshot)
{
var tick = snapshot.CachedAtTime;
var worldCompressed = GZipStream.CompressBuffer(snapshot.GameData);
var sessionCompressed = GZipStream.CompressBuffer(snapshot.SessionData);

using var hasher = SHA256.Create();
hasher.TransformBlock(worldCompressed, 0, worldCompressed.Length, null, 0);
hasher.TransformFinalBlock(sessionCompressed, 0, sessionCompressed.Length);
var hash = hasher.Hash ?? System.Array.Empty<byte>();

var packet = new ClientStandaloneWorldSnapshotPacket
{
tick = tick,
leaseVersion = 0,
worldData = worldCompressed,
sessionData = sessionCompressed,
sha256Hash = hash,
};

OnMainThread.Enqueue(() => Multiplayer.Client?.SendFragmented(packet.Serialize()));
}
}

}
34 changes: 26 additions & 8 deletions Source/Client/Session/Autosaving.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.IO;
using System.Linq;
using Multiplayer.Common;
using Multiplayer.Common.Networking.Packet;
using RimWorld;
using UnityEngine;
using Verse;
Expand All @@ -14,8 +15,18 @@ public static void DoAutosave()
{
LongEventHandler.QueueLongEvent(() =>
{
SaveGameToFile_Overwrite(GetNextAutosaveFileName(), false);
Multiplayer.Client.Send(Packets.Client_Autosaving);
if (!SaveGameToFile_Overwrite(GetNextAutosaveFileName(), false))
return;

Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Save));

// When connected to a standalone server, also upload fresh snapshots
if (Multiplayer.session?.ConnectedToStandaloneServer == true)
{
var snapshot = SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false);
SaveLoad.SendStandaloneMapSnapshots(snapshot);
SaveLoad.SendStandaloneWorldSnapshot(snapshot);
}
}, "MpSaving", false, null);
}

Expand All @@ -33,30 +44,37 @@ private static string GetNextAutosaveFileName()
.First();
}

public static void SaveGameToFile_Overwrite(string fileNameNoExtension, bool currentReplay)
public static bool SaveGameToFile_Overwrite(string fileNameNoExtension, bool currentReplay)
{
Log.Message($"Multiplayer: saving to file {fileNameNoExtension}");

try
{
var tmp = new FileInfo(Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.tmp.zip"));
Replay.ForSaving(tmp).WriteData(
var tmpPath = Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.tmp.zip");
if (File.Exists(tmpPath))
File.Delete(tmpPath);

Replay.ForSaving(new FileInfo(tmpPath)).WriteData(
currentReplay ?
Multiplayer.session.dataSnapshot :
SaveLoad.CreateGameDataSnapshot(SaveLoad.SaveGameData(), false)
);

var dst = new FileInfo(Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.zip"));
if (!dst.Exists) dst.Open(FileMode.Create).Close();
tmp.Replace(dst.FullName, null);
var dstPath = Path.Combine(Multiplayer.ReplaysDir, $"{fileNameNoExtension}.zip");
if (File.Exists(dstPath))
File.Delete(dstPath);

File.Move(tmpPath, dstPath);

Messages.Message("MpGameSaved".Translate(fileNameNoExtension), MessageTypeDefOf.SilentInput, false);
Multiplayer.session.lastSaveAt = Time.realtimeSinceStartup;
return true;
}
catch (Exception e)
{
Log.Error($"Exception saving multiplayer game as {fileNameNoExtension}: {e}");
Messages.Message("MpGameSaveFailed".Translate(), MessageTypeDefOf.SilentInput, false);
return false;
}
Comment on lines +47 to 78
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SaveGameToFile_Overwrite now swallows all exceptions and returns false instead. There are existing call sites that rely on exceptions and don’t check the return value (e.g. BootstrapConfiguratorWindow.BootstrapFlow.cs:285-288), which will now proceed as if a save succeeded even when it failed.

Either restore exception propagation (and keep the bool return only for callers that want it), or update all call sites to check the return value and abort their workflows on failure.

Copilot uses AI. Check for mistakes.
}
}
6 changes: 6 additions & 0 deletions Source/Client/Session/MultiplayerSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ public class MultiplayerSession : IConnectionStatusListener

public IConnector connector;
public BootstrapServerState bootstrapState = BootstrapServerState.None;
public bool isStandaloneServer;
public float autosaveInterval;
public AutosaveUnit autosaveUnit;
public bool ConnectedToStandaloneServer => client != null && isStandaloneServer;

public void ApplyBootstrapState(ServerBootstrapPacket packet) =>
bootstrapState = BootstrapServerState.FromPacket(packet);
Expand All @@ -64,6 +68,8 @@ public void ApplyBootstrapState(ServerBootstrapPacket packet) =>

public void Stop()
{
isStandaloneServer = false;

if (client != null)
{
client.Close(MpDisconnectReason.Internal);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,16 @@ private void CreateBootstrapReplaySave()
{
try
{
Autosaving.SaveGameToFile_Overwrite(BootstrapSaveName, currentReplay: false);
if (!Autosaving.SaveGameToFile_Overwrite(BootstrapSaveName, currentReplay: false))
{
OnMainThread.Enqueue(() =>
{
saveUploadStatus = "Save failed, see log for details.";
bootstrapSaveQueued = false;
});
return;
}

var path = Path.Combine(Multiplayer.ReplaysDir, $"{BootstrapSaveName}.zip");
OnMainThread.Enqueue(() => FinalizeBootstrapSave(path));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ private void DrawSettings(Rect entry, Rect inRect)
else if (tab == Tab.Preview)
DrawPreviewTab(contentRect, inRect.height);

settings.EnforceStandaloneRequirements(isStandaloneServer: true);

settingsUiBuffers.MaxPlayersBuffer = buffers.MaxPlayersBuffer;
settingsUiBuffers.AutosaveBuffer = buffers.AutosaveBuffer;

Expand Down Expand Up @@ -143,6 +145,7 @@ private void StartUploadSettingsToml()
{
try
{
settings.EnforceStandaloneRequirements(isStandaloneServer: true);
connection.Send(new ClientBootstrapSettingsPacket(settings));

OnMainThread.Enqueue(() =>
Expand Down
Loading
Loading