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
10 changes: 8 additions & 2 deletions Source/Client/AsyncTime/AsyncWorldTimeComp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -275,9 +275,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
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
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));
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.

DoAutosave sends JoinPointRequestReason.Save, and ServerPlayingState treats reason==Save as forceJoinPoint (bypassing the join point cooldown). That changes autosave behavior vs. the previous implementation (autosaves were not forced). Consider adding an Autosave enum value (or using Unknown) so only manual saves force join points.

Suggested change
Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Save));
Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Unknown));

Copilot uses AI. Check for mistakes.

// 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;
}
}
}
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
18 changes: 17 additions & 1 deletion Source/Client/Windows/SaveGameWindow.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using Multiplayer.Client.Util;
using Multiplayer.Common;
using RimWorld;
using System.Collections.Generic;
using System.IO;
using Multiplayer.Common.Networking.Packet;
using UnityEngine;
using Verse;

Expand Down Expand Up @@ -199,7 +201,21 @@ private void Accept(bool currentReplay)
{
if (curText.Length != 0)
{
LongEventHandler.QueueLongEvent(() => Autosaving.SaveGameToFile_Overwrite(curText, currentReplay), "MpSaving", false, null);
LongEventHandler.QueueLongEvent(() =>
{
if (!currentReplay && Multiplayer.session?.isStandaloneServer == true)
{
Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Save));
Messages.Message("MpGameSaved".Translate(curText), MessageTypeDefOf.SilentInput, false);
Multiplayer.session.lastSaveAt = Time.realtimeSinceStartup;
return;
}

if (!Autosaving.SaveGameToFile_Overwrite(curText, currentReplay))
return;

Multiplayer.Client.Send(new ClientAutosavingPacket(JoinPointRequestReason.Save));
}, "MpSaving", false, null);
Close();
}
}
Expand Down
8 changes: 8 additions & 0 deletions Source/Common/JoinPointRequestReason.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Multiplayer.Common;

public enum JoinPointRequestReason : byte
{
Unknown = 0,
Save = 1,
WorldTravel = 2,
}
1 change: 1 addition & 0 deletions Source/Common/MultiplayerServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ static MultiplayerServer()
public int NetTimer { get; private set; }

public bool IsStandaloneServer { get; set; }
public StandalonePersistence? persistence;

public MultiplayerServer(ServerSettings settings)
{
Expand Down
12 changes: 12 additions & 0 deletions Source/Common/Networking/Packet/AutosavingPacket.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Multiplayer.Common.Networking.Packet;

[PacketDefinition(Packets.Client_Autosaving)]
public record struct ClientAutosavingPacket(JoinPointRequestReason reason) : IPacket
{
public JoinPointRequestReason reason = reason;

public void Bind(PacketBuffer buf)
{
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.

ClientAutosavingPacket now carries a payload but Bind always reads it. Without a protocol bump, an older client that sends the legacy empty Client_Autosaving packet will cause the server to read past the end of the buffer. Consider either bumping MpVersion.Protocol, or making Bind tolerant (e.g., if (!buf.DataRemaining) reason = JoinPointRequestReason.Unknown).

Suggested change
{
{
if (!buf.DataRemaining)
{
reason = JoinPointRequestReason.Unknown;
return;
}

Copilot uses AI. Check for mistakes.
buf.BindEnum(ref reason);
}
}
8 changes: 7 additions & 1 deletion Source/Common/Networking/Packet/ProtocolPacket.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
namespace Multiplayer.Common.Networking.Packet;

[PacketDefinition(Packets.Server_ProtocolOk)]
public record struct ServerProtocolOkPacket(bool hasPassword) : IPacket
public record struct ServerProtocolOkPacket(bool hasPassword, bool isStandaloneServer = false) : IPacket
{
public bool hasPassword = hasPassword;
public bool isStandaloneServer = isStandaloneServer;
public float autosaveInterval;
public AutosaveUnit autosaveUnit;

public void Bind(PacketBuffer buf)
{
buf.Bind(ref hasPassword);
buf.Bind(ref isStandaloneServer);
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.

ServerProtocolOkPacket wire format changed (extra bool) but Bind always reads/writes both fields. If MpVersion.Protocol isn’t bumped, older clients/servers with the same protocol number will fail decoding and likely disconnect/crash. Either bump MpVersion.Protocol for this change, or make Bind backward-compatible (e.g., bind hasPassword first and only bind isStandaloneServer when buf.DataRemaining).

Suggested change
buf.Bind(ref isStandaloneServer);
if (buf.DataRemaining)
buf.Bind(ref isStandaloneServer);

Copilot uses AI. Check for mistakes.
buf.Bind(ref autosaveInterval);
buf.BindEnum(ref autosaveUnit);
}
}

Expand Down
39 changes: 39 additions & 0 deletions Source/Common/Networking/Packet/StandaloneSnapshotPackets.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace Multiplayer.Common.Networking.Packet;

[PacketDefinition(Packets.Client_StandaloneWorldSnapshotUpload, allowFragmented: true)]
public record struct ClientStandaloneWorldSnapshotPacket : IPacket
{
public int tick;
public int leaseVersion;
public byte[] worldData;
public byte[] sessionData;
public byte[] sha256Hash;

public void Bind(PacketBuffer buf)
{
buf.Bind(ref tick);
buf.Bind(ref leaseVersion);
buf.BindBytes(ref worldData, maxLength: -1);
buf.BindBytes(ref sessionData, maxLength: -1);
buf.BindBytes(ref sha256Hash, maxLength: 32);
}
}

[PacketDefinition(Packets.Client_StandaloneMapSnapshotUpload, allowFragmented: true)]
public record struct ClientStandaloneMapSnapshotPacket : IPacket
{
public int mapId;
public int tick;
public int leaseVersion;
public byte[] mapData;
public byte[] sha256Hash;

public void Bind(PacketBuffer buf)
{
buf.Bind(ref mapId);
buf.Bind(ref tick);
buf.Bind(ref leaseVersion);
buf.BindBytes(ref mapData, maxLength: -1);
buf.BindBytes(ref sha256Hash, maxLength: 32);
}
}
2 changes: 2 additions & 0 deletions Source/Common/Networking/Packets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ public enum Packets : byte
Client_RequestRejoin,
Client_SetFaction,
Client_FrameTime,
Client_StandaloneWorldSnapshotUpload,
Client_StandaloneMapSnapshotUpload,

// Joining
Server_ProtocolOk,
Expand Down
Loading
Loading